@ultimat3/ai 21.0.0 → 22.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 +159 -597
- package/README.md +37 -3
- package/package.json +10 -10
- package/src/agent-transcript.ts +7 -1
- package/src/agent.ts +11 -3
- package/src/budget.ts +5 -1
- package/src/gateway.ts +35 -3
- package/src/index.ts +2 -9
- package/src/llm.ts +11 -5
- package/src/pg-vector-sql.ts +13 -0
- package/src/pg-vector.ts +10 -1
- package/src/provider.ts +7 -1
- package/src/rag.ts +11 -1
- package/src/remote-embedder.ts +34 -1
- package/src/vector.ts +16 -0
- package/src/wire.ts +7 -1
package/README.md
CHANGED
|
@@ -270,7 +270,7 @@ summarize.contract(); // the contract tests
|
|
|
270
270
|
| `budget` | reserved against the worst case **before** the provider is reached — nothing spent, nothing truncated |
|
|
271
271
|
| `cache.semantic` | one store per scope, keyed by embedding; a prompt version bump reaches a different store, so the bump *is* the invalidation. `scope` receives `{ input, ctx }` and **defaults to the calling actor** — the narrowest key, `@ultimat3/query`'s `readAuthority` rule; a shared store is `scope: () => 'global'`, written down |
|
|
272
272
|
| `policy` | the same object every surface evaluates — an MCP call and an HTTP call are denied identically |
|
|
273
|
-
| `vars` | the
|
|
273
|
+
| `vars` | the declared place an `llm()` call loads data, so a reader can see what was sent — the redactor sees it (and every `agent()` tool result), and a `Secret` is refused here |
|
|
274
274
|
|
|
275
275
|
### Streaming is the same action
|
|
276
276
|
|
|
@@ -466,8 +466,9 @@ an action with no name reaches no route, no tool catalogue and no queue.
|
|
|
466
466
|
|
|
467
467
|
## Redaction: one declared seam
|
|
468
468
|
|
|
469
|
-
|
|
470
|
-
the row and a third-party endpoint.
|
|
469
|
+
A model call loads data in two places — `vars()`, and in an `agent()` the RESULT of every tool it
|
|
470
|
+
runs — and the redactor sits on both, between the row and a third-party endpoint. It sat on
|
|
471
|
+
`vars()` alone until 2026-09-23, so an agent's tool results left the process unredacted.
|
|
471
472
|
|
|
472
473
|
```ts
|
|
473
474
|
configureAi({ gateway, redact: (text) => scrubPatientIdentifiers(text) });
|
|
@@ -654,3 +655,36 @@ fail-closed `terminal` default.
|
|
|
654
655
|
| `X_VECTOR_DIM_MISMATCH` | a vector's length disagrees with the store |
|
|
655
656
|
| `X_VECTOR_SCOPE_WIDENED` | a derived vector scope tried to leave the tenant it was bound to |
|
|
656
657
|
| `X_NOT_IMPLEMENTED` | a remote driver with no key or transport; the fix names the env var |
|
|
658
|
+
|
|
659
|
+
### Error classes
|
|
660
|
+
|
|
661
|
+
Every error class `src/index.ts` exports, for `instanceof` inside one process. Across a wire or
|
|
662
|
+
a job boundary the class is gone and the `code` is what survives — match on that.
|
|
663
|
+
|
|
664
|
+
| Class | Code | Declared in |
|
|
665
|
+
|---|---|---|
|
|
666
|
+
| `AgentMaxTurnsError` | `X_AGENT_MAX_TURNS` | `src/errors.ts` |
|
|
667
|
+
| `AgentToolUnexposedError` | `X_AGENT_TOOL_UNEXPOSED` | `src/errors.ts` |
|
|
668
|
+
| `AiBudgetExceededError` | `X_AI_BUDGET_EXCEEDED` | `src/errors.ts` |
|
|
669
|
+
| `AiGatewayMissingError` | `X_AI_GATEWAY_MISSING` | `src/errors.ts` |
|
|
670
|
+
| `AiKeyMissingError` | `X_AI_KEY_MISSING` | `src/errors.ts` |
|
|
671
|
+
| `AiModelUnknownError` | `X_AI_MODEL_UNKNOWN` | `src/errors.ts` |
|
|
672
|
+
| `AiPromptRenderError` | `X_AI_PROMPT_VERSION` | `src/errors.ts` |
|
|
673
|
+
| `AiPromptSecretError` | `X_AI_PROMPT_SECRET` | `src/errors.ts` |
|
|
674
|
+
| `AiPromptVersionError` | `X_AI_PROMPT_VERSION` | `src/errors.ts` |
|
|
675
|
+
| `AiProviderUnavailableError` | `X_AI_PROVIDER_UNAVAILABLE` | `src/errors.ts` |
|
|
676
|
+
| `AiRequestInvalidError` | `X_AI_REQUEST_INVALID` | `src/errors.ts` |
|
|
677
|
+
| `AiTransportError` | `X_AI_PROVIDER_UNAVAILABLE` | `src/errors.ts` |
|
|
678
|
+
| `EmbedderDimMismatchError` | `X_VECTOR_DIM_MISMATCH` | `src/errors.ts` |
|
|
679
|
+
| `EvalBaselineInvalidError` | `X_EVAL_BASELINE_INVALID` | `src/eval-errors.ts` |
|
|
680
|
+
| `EvalBaselineMissingError` | `X_EVAL_BASELINE_MISSING` | `src/eval-errors.ts` |
|
|
681
|
+
| `EvalMissingError` | `X_EVAL_MISSING` | `src/eval-errors.ts` |
|
|
682
|
+
| `EvalRecordingError` | `X_EVAL_RECORDING` | `src/eval-errors.ts` |
|
|
683
|
+
| `EvalThresholdError` | `X_EVAL_THRESHOLD` | `src/eval-errors.ts` |
|
|
684
|
+
| `HiveEmptyError` | `X_HIVE_EMPTY` | `src/hive-errors.ts` |
|
|
685
|
+
| `LlmOutputInvalidError` | `X_LLM_OUTPUT_INVALID` | `src/errors.ts` |
|
|
686
|
+
| `LlmRefusedError` | `X_LLM_REFUSED` | `src/errors.ts` |
|
|
687
|
+
| `LlmStreamInvalidError` | `X_LLM_STREAM_INVALID` | `src/errors.ts` |
|
|
688
|
+
| `LlmTruncatedError` | `X_LLM_TRUNCATED` | `src/errors.ts` |
|
|
689
|
+
| `VectorDimMismatchError` | `X_VECTOR_DIM_MISMATCH` | `src/errors.ts` |
|
|
690
|
+
| `VectorScopeWidenedError` | `X_VECTOR_SCOPE_WIDENED` | `src/errors.ts` |
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ultimat3/ai",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "22.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": "22.0.0",
|
|
36
|
+
"@ultimat3/cache": "22.0.0",
|
|
37
|
+
"@ultimat3/core": "22.0.0",
|
|
38
|
+
"@ultimat3/db": "22.0.0",
|
|
39
|
+
"@ultimat3/jobs": "22.0.0",
|
|
40
|
+
"@ultimat3/money": "22.0.0",
|
|
41
|
+
"@ultimat3/policy": "22.0.0",
|
|
42
|
+
"@ultimat3/schema": "22.0.0",
|
|
43
|
+
"@ultimat3/time": "22.0.0"
|
|
44
44
|
}
|
|
45
45
|
}
|
package/src/agent-transcript.ts
CHANGED
|
@@ -47,6 +47,12 @@ export function toolResultTurn(
|
|
|
47
47
|
results: readonly LlmToolResult[],
|
|
48
48
|
chars: number,
|
|
49
49
|
unaccepted: readonly LlmToolCall[],
|
|
50
|
+
/**
|
|
51
|
+
* The app's `configureAi({ redact })`, run over each result BEFORE the truncation — a tool
|
|
52
|
+
* result is where an agent loads a row, and it left the process unredacted while the prompt
|
|
53
|
+
* that asked for it did not. Redacted first, so a cut never splits a match the redactor needs.
|
|
54
|
+
*/
|
|
55
|
+
redact: (text: string) => string = (text) => text,
|
|
50
56
|
): AiMessage {
|
|
51
57
|
return {
|
|
52
58
|
role: 'user',
|
|
@@ -54,7 +60,7 @@ export function toolResultTurn(
|
|
|
54
60
|
...results.map((result) => ({
|
|
55
61
|
type: 'tool_result' as const,
|
|
56
62
|
tool_use_id: result.toolUseId,
|
|
57
|
-
content: truncate(result.content, chars),
|
|
63
|
+
content: truncate(redact(result.content), chars),
|
|
58
64
|
// A denial or a failure is an outcome the model should read and react to, flagged so it
|
|
59
65
|
// does not read as data.
|
|
60
66
|
...(result.isError === true ? { is_error: true } : {}),
|
package/src/agent.ts
CHANGED
|
@@ -51,6 +51,7 @@ import type {
|
|
|
51
51
|
StopReason,
|
|
52
52
|
TokenUsage,
|
|
53
53
|
} from './provider';
|
|
54
|
+
import { isTruncated } from './provider';
|
|
54
55
|
import { assertNoSecrets } from './redaction';
|
|
55
56
|
import { aiGateway, aiRedactor } from './runtime';
|
|
56
57
|
import type { AgentTool, LlmTool, LlmToolResult, ProjectableAction } from './tools';
|
|
@@ -266,8 +267,15 @@ async function run<
|
|
|
266
267
|
'llm.redacted': rendered !== rawPrompt || system !== prompt.system,
|
|
267
268
|
});
|
|
268
269
|
|
|
269
|
-
|
|
270
|
+
// Screened first, as before: a bad declaration is refused whether or not a gateway exists.
|
|
271
|
+
const limits = limitsOf(def);
|
|
272
|
+
// Rooted in the GATEWAY's own ceilings when no scope is open — an empty root ignored them.
|
|
270
273
|
const gateway = aiGateway(name);
|
|
274
|
+
const ledger = (
|
|
275
|
+
currentBudget() ??
|
|
276
|
+
gateway.callLedger?.() ??
|
|
277
|
+
new BudgetLedger({ limits: {} })
|
|
278
|
+
).derive(limits);
|
|
271
279
|
const base: GenerateRequest = {
|
|
272
280
|
model,
|
|
273
281
|
...(system === undefined ? {} : { system }),
|
|
@@ -340,7 +348,7 @@ async function run<
|
|
|
340
348
|
// the loop continues, and the answer is REJECTED on the wire rather than dropped,
|
|
341
349
|
// because a replayed `respond` block with no `tool_result` is a 400 and the whole run
|
|
342
350
|
// becomes an `X_AI_PROVIDER_UNAVAILABLE`.
|
|
343
|
-
toolResultTurn(results, chars, answers),
|
|
351
|
+
toolResultTurn(results, chars, answers, redact),
|
|
344
352
|
];
|
|
345
353
|
continue;
|
|
346
354
|
}
|
|
@@ -350,7 +358,7 @@ async function run<
|
|
|
350
358
|
// A wrong shape gets another turn like any other, because unlike `llm()` this loop has
|
|
351
359
|
// turns left by construction — and unlike a tool result, the correction is the message.
|
|
352
360
|
issues = formatIssues(parsed.issues).join('; ');
|
|
353
|
-
if (result.stopReason
|
|
361
|
+
if (isTruncated(result.stopReason)) {
|
|
354
362
|
throw new LlmTruncatedError({ prompt: name, maxTokens: base.maxTokens });
|
|
355
363
|
}
|
|
356
364
|
messages = [...messages, assistantTurn(result), repairTurn(answers, issues)];
|
package/src/budget.ts
CHANGED
|
@@ -19,7 +19,11 @@ import { estimateCost, estimateInputTokens, estimateTokens, totalTokens } from '
|
|
|
19
19
|
|
|
20
20
|
/** Ceilings. An omitted scope is unlimited — declare the ones that matter. */
|
|
21
21
|
export interface BudgetLimits {
|
|
22
|
-
/**
|
|
22
|
+
/**
|
|
23
|
+
* Token ceiling for one call CHAIN — every `generate`/`stream` sharing a ledger, pre-flight
|
|
24
|
+
* estimates included. An `llm()` repair turn and an `agent()` turn each debit the same ledger,
|
|
25
|
+
* so this is not "one provider call" once a scope is open.
|
|
26
|
+
*/
|
|
23
27
|
readonly request?: number;
|
|
24
28
|
/**
|
|
25
29
|
* Prompt-token ceiling for ONE call. Distinct from `request`, which counts the completion
|
package/src/gateway.ts
CHANGED
|
@@ -76,6 +76,12 @@ export interface Gateway {
|
|
|
76
76
|
scope<T>(input: { actorKey?: string; orgKey?: string }, fn: () => Promise<T>): Promise<T>;
|
|
77
77
|
/** Accumulated cost of the ambient scope, or zero outside one. */
|
|
78
78
|
spent(): Promise<Money>;
|
|
79
|
+
/**
|
|
80
|
+
* A ledger over this gateway's own `budget`, for a call made with no `scope()` open — which is
|
|
81
|
+
* every call `llm()` and `agent()` make. Optional so a hand-written gateway still satisfies the
|
|
82
|
+
* interface; absent, such a call runs under the ceilings its own declaration sets and no others.
|
|
83
|
+
*/
|
|
84
|
+
callLedger?(): BudgetLedger;
|
|
79
85
|
}
|
|
80
86
|
|
|
81
87
|
export function createGateway(input: CreateGatewayInput): Gateway {
|
|
@@ -105,6 +111,18 @@ class GatewayImpl implements Gateway {
|
|
|
105
111
|
this.random = config.random;
|
|
106
112
|
}
|
|
107
113
|
|
|
114
|
+
/**
|
|
115
|
+
* `budget` held with no scope open. It was enforced ONLY inside `scope()`, which nothing in the
|
|
116
|
+
* framework calls — so `createGateway({ budget: { request: 100 } })` capped no call any app made.
|
|
117
|
+
* No actor or org key here: those scopes need `scope()` to name whose window they are.
|
|
118
|
+
*/
|
|
119
|
+
callLedger(): BudgetLedger {
|
|
120
|
+
return new BudgetLedger({
|
|
121
|
+
limits: this.config.budget ?? {},
|
|
122
|
+
...(this.config.budgetStore !== undefined ? { store: this.config.budgetStore } : {}),
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
|
|
108
126
|
scope<T>(input: { actorKey?: string; orgKey?: string }, fn: () => Promise<T>): Promise<T> {
|
|
109
127
|
const ledger = new BudgetLedger({
|
|
110
128
|
limits: this.config.budget ?? {},
|
|
@@ -135,7 +153,7 @@ class GatewayImpl implements Gateway {
|
|
|
135
153
|
// Reserve against the ESTIMATE before spending anything — tokens AND money, since a
|
|
136
154
|
// cheap-in-tokens call on an expensive model is still a cost cap the app declared.
|
|
137
155
|
// `record` below replaces the estimate with the provider's real counts.
|
|
138
|
-
const ledger = currentBudget();
|
|
156
|
+
const ledger = currentBudget() ?? this.callLedger();
|
|
139
157
|
// The estimate is DEBITED here, not merely checked: three concurrent calls under one ledger
|
|
140
158
|
// all read the same `spent()` otherwise, all pass, and all three record against a ceiling
|
|
141
159
|
// only one of them fitted.
|
|
@@ -178,7 +196,7 @@ class GatewayImpl implements Gateway {
|
|
|
178
196
|
// `generate`, or reconnects itself and knows what it has already shown.
|
|
179
197
|
const provider = this.providerFor(model);
|
|
180
198
|
|
|
181
|
-
const ledger = currentBudget();
|
|
199
|
+
const ledger = currentBudget() ?? this.callLedger();
|
|
182
200
|
const reservation = await ledger?.reserve(estimateSpend(resolved));
|
|
183
201
|
let settled = false;
|
|
184
202
|
try {
|
|
@@ -319,6 +337,20 @@ export function backoffMs(policy: RetryPolicy, attempt: number, random?: Random)
|
|
|
319
337
|
* package was how it stayed invisible. The `code` branch has no equivalent in core and stays here:
|
|
320
338
|
* that table is HTTP status only, and a socket that timed out never produced one.
|
|
321
339
|
*/
|
|
340
|
+
/**
|
|
341
|
+
* A transport that never produced a response. Node spells these `ETIMEDOUT`/`ECONNRESET`; Bun's
|
|
342
|
+
* `fetch` spells a refused, dropped or unopenable connection `ConnectionRefused`,
|
|
343
|
+
* `ConnectionClosed` and `FailedToOpenSocket` — which this list did not know, so on the one runtime
|
|
344
|
+
* the framework runs on a provider mid-restart was never retried.
|
|
345
|
+
*/
|
|
346
|
+
const RETRYABLE_TRANSPORT_CODES: ReadonlySet<string> = new Set([
|
|
347
|
+
'ETIMEDOUT',
|
|
348
|
+
'ECONNRESET',
|
|
349
|
+
'ConnectionRefused',
|
|
350
|
+
'ConnectionClosed',
|
|
351
|
+
'FailedToOpenSocket',
|
|
352
|
+
]);
|
|
353
|
+
|
|
322
354
|
export function isRetryable(error: unknown): boolean {
|
|
323
355
|
if (typeof error !== 'object' || error === null) return false;
|
|
324
356
|
// A `Provider` is the APP's object, so the value it rejected with is one the framework did not
|
|
@@ -328,7 +360,7 @@ export function isRetryable(error: unknown): boolean {
|
|
|
328
360
|
try {
|
|
329
361
|
const e = error as { status?: unknown; code?: unknown };
|
|
330
362
|
if (typeof e.status === 'number') return isRetryableStatus(e.status);
|
|
331
|
-
return e.code === '
|
|
363
|
+
return typeof e.code === 'string' && RETRYABLE_TRANSPORT_CODES.has(e.code);
|
|
332
364
|
} catch {
|
|
333
365
|
return false;
|
|
334
366
|
}
|
package/src/index.ts
CHANGED
|
@@ -63,7 +63,6 @@ export type { EvalBaseline, Regression } from './eval-baseline';
|
|
|
63
63
|
export {
|
|
64
64
|
baselinePath,
|
|
65
65
|
describeRegression,
|
|
66
|
-
OVERALL,
|
|
67
66
|
RECORD_ENV,
|
|
68
67
|
readBaseline,
|
|
69
68
|
recordingBaselines,
|
|
@@ -113,7 +112,6 @@ export {
|
|
|
113
112
|
modelIds,
|
|
114
113
|
modelSpec,
|
|
115
114
|
moreCapableThan,
|
|
116
|
-
reasoningBody,
|
|
117
115
|
registeredModels,
|
|
118
116
|
registerModel,
|
|
119
117
|
resetModels,
|
|
@@ -133,11 +131,7 @@ export {
|
|
|
133
131
|
conditionsSql,
|
|
134
132
|
ddlSql,
|
|
135
133
|
deleteSql,
|
|
136
|
-
hybridSql,
|
|
137
134
|
searchSql,
|
|
138
|
-
textSql,
|
|
139
|
-
upsertSql,
|
|
140
|
-
vectorLiteral,
|
|
141
135
|
} from './pg-vector-sql';
|
|
142
136
|
export type { DefinePromptInput, Prompt, PromptVars } from './prompt';
|
|
143
137
|
export {
|
|
@@ -169,6 +163,7 @@ export {
|
|
|
169
163
|
estimateInputTokens,
|
|
170
164
|
estimateTextTokens,
|
|
171
165
|
estimateTokens,
|
|
166
|
+
isTruncated,
|
|
172
167
|
messageText,
|
|
173
168
|
parseMessage,
|
|
174
169
|
requiresStreaming,
|
|
@@ -193,7 +188,6 @@ export {
|
|
|
193
188
|
aiRedactor,
|
|
194
189
|
configureAi,
|
|
195
190
|
MAX_SEMANTIC_CACHE_SCOPES,
|
|
196
|
-
resetAiRuntime,
|
|
197
191
|
semanticCacheFor,
|
|
198
192
|
} from './runtime';
|
|
199
193
|
export type { Scorer } from './scorers';
|
|
@@ -225,6 +219,5 @@ export type {
|
|
|
225
219
|
} from './vector';
|
|
226
220
|
export { fuse, MemoryVectorStore } from './vector';
|
|
227
221
|
export type { VectorScope } from './vector-scope';
|
|
228
|
-
export { NO_TENANT,
|
|
222
|
+
export { NO_TENANT, tenantOf, UNSCOPED } from './vector-scope';
|
|
229
223
|
export type { StreamState } from './wire';
|
|
230
|
-
export { parseStopDetails, ZERO_USAGE } from './wire';
|
package/src/llm.ts
CHANGED
|
@@ -45,6 +45,7 @@ import type { ModelId } from './models';
|
|
|
45
45
|
import { DEFAULT_MODEL, moreCapableThan } from './models';
|
|
46
46
|
import type { Prompt, PromptVars } from './prompt';
|
|
47
47
|
import type { AiMessage, GenerateRequest, GenerateResult } from './provider';
|
|
48
|
+
import { isTruncated } from './provider';
|
|
48
49
|
import { assertNoSecrets } from './redaction';
|
|
49
50
|
import { aiGateway, aiRedactor } from './runtime';
|
|
50
51
|
import type { LlmTool } from './tools';
|
|
@@ -254,10 +255,15 @@ async function generate<
|
|
|
254
255
|
// A ledger derived from the ambient one, so a per-call budget can only TIGHTEN the actor
|
|
255
256
|
// and org ceilings this call runs inside, never widen them. The gateway reserves against
|
|
256
257
|
// it before the provider is touched — that is where `X_AI_BUDGET_EXCEEDED` comes from.
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
258
|
+
// Screened first, as before: a bad declaration is refused whether or not a gateway exists.
|
|
259
|
+
const limits = limitsOf(def.budget);
|
|
260
|
+
// Rooted in the GATEWAY's own ceilings when no scope is open — an empty root ignored them.
|
|
260
261
|
const gateway = aiGateway(name);
|
|
262
|
+
const ledger = (
|
|
263
|
+
currentBudget() ??
|
|
264
|
+
gateway.callLedger?.() ??
|
|
265
|
+
new BudgetLedger({ limits: {} })
|
|
266
|
+
).derive(limits);
|
|
261
267
|
const sink = currentLlmSink();
|
|
262
268
|
|
|
263
269
|
return withBudget(ledger, async () => {
|
|
@@ -293,7 +299,7 @@ async function generate<
|
|
|
293
299
|
}
|
|
294
300
|
// A cut-off answer that also fails its schema is not a disagreement about the shape: the
|
|
295
301
|
// ceiling is the same on the next attempt, so the repair turn is a second truncation.
|
|
296
|
-
if (result.stopReason
|
|
302
|
+
if (isTruncated(result.stopReason)) {
|
|
297
303
|
throw new LlmTruncatedError({ prompt: name, maxTokens: request.maxTokens });
|
|
298
304
|
}
|
|
299
305
|
issues = formatIssues(parsed.issues).join('; ');
|
|
@@ -352,7 +358,7 @@ async function streamedAnswer<TOutput extends StandardSchemaV1>(
|
|
|
352
358
|
explanation: result.stopDetails?.explanation,
|
|
353
359
|
});
|
|
354
360
|
}
|
|
355
|
-
if (result.stopReason
|
|
361
|
+
if (isTruncated(result.stopReason)) {
|
|
356
362
|
throw new LlmTruncatedError({ prompt: name, maxTokens: request.maxTokens });
|
|
357
363
|
}
|
|
358
364
|
// Prose, and its JSON parse when it has one. Both, because a stream carries no `respond` tool
|
package/src/pg-vector-sql.ts
CHANGED
|
@@ -192,6 +192,19 @@ order by f.score desc, d."id" asc
|
|
|
192
192
|
limit ${args.k}`;
|
|
193
193
|
}
|
|
194
194
|
|
|
195
|
+
/** Every row the scope and `filter` admit, except the ids in `keep` — a re-index's cleanup. */
|
|
196
|
+
export function pruneSql(
|
|
197
|
+
target: PgVectorTable,
|
|
198
|
+
scope: VectorScope,
|
|
199
|
+
filter: MetadataFilter,
|
|
200
|
+
keep: readonly string[],
|
|
201
|
+
): SqlFragment {
|
|
202
|
+
const kept =
|
|
203
|
+
keep.length === 0 ? ALWAYS : sql`"id" not in (${join(keep.map((id) => sql`${id}`))})`;
|
|
204
|
+
return sql`delete from ${identifier(target.table)}
|
|
205
|
+
where ${kept} and ${conditionsSql(scope, filter)}`;
|
|
206
|
+
}
|
|
207
|
+
|
|
195
208
|
export function deleteSql(
|
|
196
209
|
target: PgVectorTable,
|
|
197
210
|
scope: VectorScope,
|
package/src/pg-vector.ts
CHANGED
|
@@ -12,6 +12,7 @@ import {
|
|
|
12
12
|
hybridSql,
|
|
13
13
|
type PgHybridArgs,
|
|
14
14
|
type PgVectorTable,
|
|
15
|
+
pruneSql,
|
|
15
16
|
searchSql,
|
|
16
17
|
textSql,
|
|
17
18
|
upsertSql,
|
|
@@ -87,11 +88,15 @@ export class PgVectorStore implements VectorStore {
|
|
|
87
88
|
async upsert(records: readonly VectorRecord[]): Promise<void> {
|
|
88
89
|
if (records.length === 0) return;
|
|
89
90
|
for (const record of records) this.assertDimension(record.vector.length);
|
|
91
|
+
// One row per id, the LAST one winning — what `MemoryVectorStore` answers. Postgres refuses a
|
|
92
|
+
// statement whose `on conflict do update` touches one row twice, so a batch carrying an id
|
|
93
|
+
// twice failed here and succeeded there.
|
|
94
|
+
const unique = [...new Map(records.map((record) => [record.id, record])).values()];
|
|
90
95
|
await this.client().execute(
|
|
91
96
|
upsertSql(
|
|
92
97
|
this.target,
|
|
93
98
|
tenantOf(this.scope),
|
|
94
|
-
|
|
99
|
+
unique.map((record) => ({
|
|
95
100
|
id: record.id,
|
|
96
101
|
vector: record.vector,
|
|
97
102
|
text: record.text,
|
|
@@ -142,6 +147,10 @@ export class PgVectorStore implements VectorStore {
|
|
|
142
147
|
return this.run(hybridSql(this.target, input.query, input.vector, args));
|
|
143
148
|
}
|
|
144
149
|
|
|
150
|
+
async prune(filter: MetadataFilter, keep: readonly string[]): Promise<void> {
|
|
151
|
+
await this.client().execute(pruneSql(this.target, this.scope, filter, keep));
|
|
152
|
+
}
|
|
153
|
+
|
|
145
154
|
async delete(ids: readonly string[]): Promise<void> {
|
|
146
155
|
if (ids.length === 0) return;
|
|
147
156
|
await this.client().execute(deleteSql(this.target, this.scope, ids));
|
package/src/provider.ts
CHANGED
|
@@ -102,7 +102,13 @@ export type StopReason =
|
|
|
102
102
|
| 'stop_sequence'
|
|
103
103
|
| 'tool_use'
|
|
104
104
|
| 'pause_turn'
|
|
105
|
-
| 'refusal'
|
|
105
|
+
| 'refusal'
|
|
106
|
+
/** The prompt plus the answer outgrew the model's context window: cut off, like `max_tokens`. */
|
|
107
|
+
| 'model_context_window_exceeded';
|
|
108
|
+
|
|
109
|
+
/** Whether an answer was CUT OFF — never parse one of these as a complete answer. */
|
|
110
|
+
export const isTruncated = (reason: StopReason): boolean =>
|
|
111
|
+
reason === 'max_tokens' || reason === 'model_context_window_exceeded';
|
|
106
112
|
|
|
107
113
|
/**
|
|
108
114
|
* Why a refusal happened. Only ever present when `stopReason` is `refusal`, and `category` is
|
package/src/rag.ts
CHANGED
|
@@ -154,7 +154,12 @@ function cutToBudget(run: string, size: number): readonly string[] {
|
|
|
154
154
|
return pieces;
|
|
155
155
|
}
|
|
156
156
|
|
|
157
|
-
/**
|
|
157
|
+
/**
|
|
158
|
+
* Index a document: chunk, embed, upsert, prune. One call so no step is skipped by accident. The
|
|
159
|
+
* prune is what re-indexing a SHORTER document needs: chunk ids are `<doc>#<n>`, so the upsert
|
|
160
|
+
* overwrote the first chunks and left the old tail retrievable. Upsert first, prune after, so the
|
|
161
|
+
* document is never absent from a search in between. A store without `prune` cannot clean up.
|
|
162
|
+
*/
|
|
158
163
|
export async function indexDocument(input: {
|
|
159
164
|
readonly store: VectorStore;
|
|
160
165
|
readonly embedder: Embedder;
|
|
@@ -176,6 +181,11 @@ export async function indexDocument(input: {
|
|
|
176
181
|
metadata: c.metadata,
|
|
177
182
|
})),
|
|
178
183
|
);
|
|
184
|
+
// `source` is the chunker's own stamp (`chunk()`), so only THIS document's rows are touched.
|
|
185
|
+
await input.store.prune?.(
|
|
186
|
+
{ source: input.document.id },
|
|
187
|
+
chunks.map((c) => c.id),
|
|
188
|
+
);
|
|
179
189
|
return chunks;
|
|
180
190
|
}
|
|
181
191
|
|
package/src/remote-embedder.ts
CHANGED
|
@@ -132,7 +132,7 @@ export class RemoteEmbedder implements Embedder {
|
|
|
132
132
|
throw new AiTransportError({
|
|
133
133
|
provider: this.name,
|
|
134
134
|
status: response.status,
|
|
135
|
-
detail:
|
|
135
|
+
detail: await detailOf(response.body),
|
|
136
136
|
envVar: API_KEY_ENV,
|
|
137
137
|
});
|
|
138
138
|
}
|
|
@@ -218,3 +218,36 @@ function asRecord(value: unknown): Record<string, unknown> | undefined {
|
|
|
218
218
|
function countOf(data: unknown): string {
|
|
219
219
|
return Array.isArray(data) ? String(data.length) : 'no data array';
|
|
220
220
|
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* The start of an error body, and never more. `response.text()` read it WHOLE — outside
|
|
224
|
+
* `maxResponseBytes`, which bounds a success only — so a provider streaming an endless 5xx body
|
|
225
|
+
* held the call and its memory forever. Capped through the same counting reader, and decoded from
|
|
226
|
+
* the bytes it kept: `over` still has a detail worth showing.
|
|
227
|
+
*/
|
|
228
|
+
async function detailOf(body: ReadableStream<Uint8Array> | null): Promise<string> {
|
|
229
|
+
if (body === null) return '';
|
|
230
|
+
const reader = body.getReader();
|
|
231
|
+
const kept: Uint8Array[] = [];
|
|
232
|
+
let total = 0;
|
|
233
|
+
try {
|
|
234
|
+
while (total < DETAIL_LIMIT) {
|
|
235
|
+
const { done, value } = await reader.read();
|
|
236
|
+
if (done) break;
|
|
237
|
+
kept.push(value);
|
|
238
|
+
total += value.byteLength;
|
|
239
|
+
}
|
|
240
|
+
} catch {
|
|
241
|
+
// A body that fails mid-read still has whatever arrived; the status is the error's substance.
|
|
242
|
+
} finally {
|
|
243
|
+
await reader.cancel().catch(() => undefined);
|
|
244
|
+
reader.releaseLock();
|
|
245
|
+
}
|
|
246
|
+
const bytes = new Uint8Array(total);
|
|
247
|
+
let offset = 0;
|
|
248
|
+
for (const chunk of kept) {
|
|
249
|
+
bytes.set(chunk, offset);
|
|
250
|
+
offset += chunk.byteLength;
|
|
251
|
+
}
|
|
252
|
+
return new TextDecoder().decode(bytes).slice(0, DETAIL_LIMIT);
|
|
253
|
+
}
|
package/src/vector.ts
CHANGED
|
@@ -53,6 +53,13 @@ export interface VectorStore {
|
|
|
53
53
|
searchText(query: string, k: number, filter?: MetadataFilter): Promise<readonly SearchHit[]>;
|
|
54
54
|
hybrid(input: HybridSearchInput): Promise<readonly SearchHit[]>;
|
|
55
55
|
delete(ids: readonly string[]): Promise<void>;
|
|
56
|
+
/**
|
|
57
|
+
* Delete every row `filter` matches EXCEPT the ids in `keep`, inside this store's scope. What a
|
|
58
|
+
* re-index needs: `indexDocument` upserts a document's new chunks, then prunes the ones the
|
|
59
|
+
* shorter text no longer produced. Optional so a hand-written store still satisfies the type;
|
|
60
|
+
* without it a re-index cannot clean up, and says so in `indexDocument`'s doc.
|
|
61
|
+
*/
|
|
62
|
+
prune?(filter: MetadataFilter, keep: readonly string[]): Promise<void>;
|
|
56
63
|
}
|
|
57
64
|
|
|
58
65
|
export interface MemoryVectorStoreInput {
|
|
@@ -188,6 +195,15 @@ export class MemoryVectorStore implements VectorStore {
|
|
|
188
195
|
return fuse([dense, lexical], rrfK).slice(0, k);
|
|
189
196
|
}
|
|
190
197
|
|
|
198
|
+
/** Scoped and filtered, exactly like `pruneSql`: everything `filter` matches but `keep`. */
|
|
199
|
+
async prune(filter: MetadataFilter, keep: readonly string[]): Promise<void> {
|
|
200
|
+
const kept = new Set(keep);
|
|
201
|
+
for (const record of this.candidates(filter)) {
|
|
202
|
+
if (kept.has(record.id)) continue;
|
|
203
|
+
for (const [key, stored] of this.records) if (stored === record) this.records.delete(key);
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
191
207
|
/** Scoped, exactly like the SQL `delete ... where id in (...) and <scope>`. */
|
|
192
208
|
async delete(ids: readonly string[]): Promise<void> {
|
|
193
209
|
const wanted = new Set(ids);
|
package/src/wire.ts
CHANGED
|
@@ -59,10 +59,16 @@ const STOP_REASONS: readonly StopReason[] = [
|
|
|
59
59
|
'tool_use',
|
|
60
60
|
'pause_turn',
|
|
61
61
|
'refusal',
|
|
62
|
+
'model_context_window_exceeded',
|
|
62
63
|
];
|
|
63
64
|
|
|
65
|
+
/**
|
|
66
|
+
* FAILS CLOSED: a reason this list does not know is read as `max_tokens`. It fell back to
|
|
67
|
+
* `end_turn`, so `model_context_window_exceeded` — a cut-off answer — was parsed as a complete
|
|
68
|
+
* one. A provider's new reason is far likelier to be a way of stopping early than of finishing.
|
|
69
|
+
*/
|
|
64
70
|
export function parseStopReason(raw: unknown): StopReason {
|
|
65
|
-
return STOP_REASONS.find((reason) => reason === raw) ?? '
|
|
71
|
+
return STOP_REASONS.find((reason) => reason === raw) ?? 'max_tokens';
|
|
66
72
|
}
|
|
67
73
|
|
|
68
74
|
/**
|