@ultimat3/ai 23.0.0 → 24.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 +34 -6
- package/README.md +45 -12
- package/package.json +11 -10
- package/src/agent-transcript.ts +1 -1
- package/src/agent.ts +14 -13
- package/src/budget.ts +138 -46
- package/src/embeddings.ts +10 -11
- package/src/error-body.ts +38 -3
- package/src/errors.ts +2 -0
- package/src/eval-baseline.ts +5 -1
- package/src/gateway.ts +33 -21
- package/src/hive.ts +10 -2
- package/src/index.ts +4 -2
- package/src/llm-cache.ts +3 -2
- package/src/llm.ts +13 -55
- package/src/openai-wire.ts +7 -2
- package/src/pg-vector.ts +7 -4
- package/src/provider.ts +9 -7
- package/src/rag.ts +10 -3
- package/src/remote-embedder.ts +5 -36
- package/src/respond.ts +86 -0
- package/src/runtime.ts +5 -0
- package/src/scorers.ts +13 -2
- package/src/tools.ts +30 -19
- package/src/vector-scope.ts +44 -2
- package/src/vector.ts +16 -6
package/src/embeddings.ts
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
// queried with another is a silent relevance collapse, and the only place to catch it is
|
|
6
6
|
// where the two meet. `VectorStore` compares the declared dimension and refuses.
|
|
7
7
|
|
|
8
|
-
import { finiteCount } from '@ultimat3/core';
|
|
8
|
+
import { fingerprint, finiteCount } from '@ultimat3/core';
|
|
9
9
|
import { AiEmbedderInvalidError } from './errors';
|
|
10
10
|
|
|
11
11
|
export interface Embedder {
|
|
@@ -88,10 +88,10 @@ export class HashEmbedder implements Embedder {
|
|
|
88
88
|
private one(text: string): Float32Array {
|
|
89
89
|
const vector = new Float32Array(this.dimension);
|
|
90
90
|
for (const token of tokenize(text)) {
|
|
91
|
-
const slot =
|
|
91
|
+
const slot = hashOf(token) % this.dimension;
|
|
92
92
|
// Signed accumulation: without it every vector is non-negative and cosine
|
|
93
93
|
// similarity compresses into a narrow band where nothing ranks apart.
|
|
94
|
-
const sign =
|
|
94
|
+
const sign = hashOf(`${token}#sign`) % 2 === 0 ? 1 : -1;
|
|
95
95
|
vector[slot] = (vector[slot] ?? 0) + sign;
|
|
96
96
|
}
|
|
97
97
|
return normalize(vector);
|
|
@@ -103,14 +103,13 @@ export function tokenize(text: string): readonly string[] {
|
|
|
103
103
|
return text.toLowerCase().match(/[a-z0-9]+/g) ?? [];
|
|
104
104
|
}
|
|
105
105
|
|
|
106
|
-
/**
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
return hash;
|
|
106
|
+
/**
|
|
107
|
+
* A token's slot hash: 32 bits of core's `fingerprint`, never a local FNV-1a. FNV-1a/32 collides on
|
|
108
|
+
* ordinary words (`costarring` / `liquid`), and two colliding words embed IDENTICALLY here — cosine
|
|
109
|
+
* 1, so a semantic lookup answered one with the other's entry.
|
|
110
|
+
*/
|
|
111
|
+
function hashOf(text: string): number {
|
|
112
|
+
return Number.parseInt(fingerprint(text).slice(0, 8), 16);
|
|
114
113
|
}
|
|
115
114
|
|
|
116
115
|
/** L2 normalise in place so cosine similarity reduces to a dot product. */
|
package/src/error-body.ts
CHANGED
|
@@ -11,13 +11,20 @@ import { REDACTED } from '@ultimat3/core';
|
|
|
11
11
|
/** Enough of an error body to name the field that was wrong, not enough to fill a log. */
|
|
12
12
|
const DETAIL_LIMIT = 300;
|
|
13
13
|
|
|
14
|
+
/**
|
|
15
|
+
* How much of a failure body is READ. Larger than what is shown, because the message sits inside a
|
|
16
|
+
* JSON envelope that has to parse; bounded, because a provider streaming an endless 5xx body held
|
|
17
|
+
* the call and its memory for as long as it kept sending.
|
|
18
|
+
*/
|
|
19
|
+
const READ_LIMIT = 4 * 1024;
|
|
20
|
+
|
|
14
21
|
/**
|
|
15
22
|
* The provider's own message, when it sent one — it names the offending field, we name the fix.
|
|
16
23
|
* Falls back to the raw text because a proxy or a gateway timeout page is not JSON and is still
|
|
17
|
-
* the best evidence there is.
|
|
24
|
+
* the best evidence there is. One reader for every transport, the embedder included.
|
|
18
25
|
*/
|
|
19
26
|
export async function detailOf(response: Response): Promise<string> {
|
|
20
|
-
const body = await response.
|
|
27
|
+
const body = await boundedText(response.body);
|
|
21
28
|
try {
|
|
22
29
|
const parsed: unknown = JSON.parse(body);
|
|
23
30
|
if (typeof parsed === 'object' && parsed !== null) {
|
|
@@ -28,11 +35,39 @@ export async function detailOf(response: Response): Promise<string> {
|
|
|
28
35
|
}
|
|
29
36
|
}
|
|
30
37
|
} catch {
|
|
31
|
-
// Not JSON — a proxy or a gateway timeout page
|
|
38
|
+
// Not JSON — a proxy or a gateway timeout page, or a body cut at the read limit.
|
|
32
39
|
}
|
|
33
40
|
return body === '' ? response.statusText : body.slice(0, DETAIL_LIMIT);
|
|
34
41
|
}
|
|
35
42
|
|
|
43
|
+
/** The start of a body, never more than `READ_LIMIT` bytes of it; the rest is cancelled. */
|
|
44
|
+
async function boundedText(body: ReadableStream<Uint8Array> | null): Promise<string> {
|
|
45
|
+
if (body === null) return '';
|
|
46
|
+
const reader = body.getReader();
|
|
47
|
+
const kept: Uint8Array[] = [];
|
|
48
|
+
let total = 0;
|
|
49
|
+
try {
|
|
50
|
+
while (total < READ_LIMIT) {
|
|
51
|
+
const { done, value } = await reader.read();
|
|
52
|
+
if (done) break;
|
|
53
|
+
kept.push(value);
|
|
54
|
+
total += value.byteLength;
|
|
55
|
+
}
|
|
56
|
+
} catch {
|
|
57
|
+
// A body that fails mid-read still has whatever arrived; the status is the error's substance.
|
|
58
|
+
} finally {
|
|
59
|
+
await reader.cancel().catch(() => undefined);
|
|
60
|
+
reader.releaseLock();
|
|
61
|
+
}
|
|
62
|
+
const bytes = new Uint8Array(total);
|
|
63
|
+
let offset = 0;
|
|
64
|
+
for (const chunk of kept) {
|
|
65
|
+
bytes.set(chunk, offset);
|
|
66
|
+
offset += chunk.byteLength;
|
|
67
|
+
}
|
|
68
|
+
return new TextDecoder().decode(bytes);
|
|
69
|
+
}
|
|
70
|
+
|
|
36
71
|
/**
|
|
37
72
|
* Every occurrence of the credential replaced with `[redacted]`. Cheap, and the one leak path:
|
|
38
73
|
* a proxy that echoes the request headers into its own 400 body puts the key in an error, and an
|
package/src/errors.ts
CHANGED
|
@@ -27,6 +27,7 @@ export const AI_ERROR_CODES = [
|
|
|
27
27
|
'X_VECTOR_DIM_MISMATCH',
|
|
28
28
|
'X_VECTOR_SCOPE_WIDENED',
|
|
29
29
|
'X_AI_EMBEDDER_INVALID',
|
|
30
|
+
'X_VECTOR_UNSCOPED',
|
|
30
31
|
] as const;
|
|
31
32
|
|
|
32
33
|
export type AiErrorCode = (typeof AI_ERROR_CODES)[number];
|
|
@@ -55,6 +56,7 @@ export const AI_ERROR_TITLES: Readonly<Record<AiErrorCode, string>> = {
|
|
|
55
56
|
X_VECTOR_DIM_MISMATCH: 'embedding dimensions differ from the store',
|
|
56
57
|
X_VECTOR_SCOPE_WIDENED: 'a derived vector scope tried to leave its tenant',
|
|
57
58
|
X_AI_EMBEDDER_INVALID: 'an Embedder returned fewer vectors than texts it was given',
|
|
59
|
+
X_VECTOR_UNSCOPED: 'a vector store was read with no tenant bound inside an org request',
|
|
58
60
|
};
|
|
59
61
|
|
|
60
62
|
// Titles must be registered for `format()` to render the contract's first line. Unconditional and
|
package/src/eval-baseline.ts
CHANGED
|
@@ -91,7 +91,11 @@ export function regressionsAgainst(input: {
|
|
|
91
91
|
readonly tolerance: number;
|
|
92
92
|
}): readonly Regression[] {
|
|
93
93
|
const found: Regression[] = [];
|
|
94
|
-
|
|
94
|
+
// Both sides at the precision the baseline is WRITTEN at, and the drop rounded too: a raw 2/3
|
|
95
|
+
// against its own recorded 0.667 fell at `tolerance: 0`, and `0.9 - 0.05` is not `0.85` in
|
|
96
|
+
// binary — a drop of exactly the tolerance must read as within it.
|
|
97
|
+
const fell = (was: number, now: number): boolean =>
|
|
98
|
+
round(round(was) - round(now)) > input.tolerance;
|
|
95
99
|
if (fell(input.baseline.score, input.score)) {
|
|
96
100
|
found.push({ case: OVERALL, baseline: input.baseline.score, score: input.score });
|
|
97
101
|
}
|
package/src/gateway.ts
CHANGED
|
@@ -7,14 +7,16 @@
|
|
|
7
7
|
import type { Random } from '@ultimat3/core';
|
|
8
8
|
import {
|
|
9
9
|
backoffDelay,
|
|
10
|
+
fingerprint,
|
|
10
11
|
finiteCount,
|
|
11
12
|
isRetryableStatus,
|
|
12
13
|
isUltimateError,
|
|
14
|
+
logger,
|
|
13
15
|
renderThrowable,
|
|
14
16
|
stringField,
|
|
15
17
|
} from '@ultimat3/core';
|
|
16
18
|
import type { Money } from '@ultimat3/money';
|
|
17
|
-
import type { BudgetLimits, BudgetStore } from './budget';
|
|
19
|
+
import type { BudgetKeys, BudgetLimits, BudgetStore } from './budget';
|
|
18
20
|
import { BudgetLedger, currentBudget, estimateSpend, withBudget } from './budget';
|
|
19
21
|
import { AiProviderUnavailableError } from './errors';
|
|
20
22
|
import type { ModelId } from './models';
|
|
@@ -78,10 +80,11 @@ export interface Gateway {
|
|
|
78
80
|
spent(): Promise<Money>;
|
|
79
81
|
/**
|
|
80
82
|
* A ledger over this gateway's own `budget`, for a call made with no `scope()` open — which is
|
|
81
|
-
* every call `llm()` and `
|
|
82
|
-
*
|
|
83
|
+
* every call `llm()`, `agent()` and `hive()` make, each passing `budgetKeysFor(ctx.actor)` so the
|
|
84
|
+
* `actor` and `org` ceilings count the caller. Required: a gateway without it ran every such call
|
|
85
|
+
* under no gateway ceiling at all.
|
|
83
86
|
*/
|
|
84
|
-
callLedger
|
|
87
|
+
callLedger(keys: BudgetKeys): BudgetLedger;
|
|
85
88
|
}
|
|
86
89
|
|
|
87
90
|
export function createGateway(input: CreateGatewayInput): Gateway {
|
|
@@ -114,23 +117,19 @@ class GatewayImpl implements Gateway {
|
|
|
114
117
|
/**
|
|
115
118
|
* `budget` held with no scope open. It was enforced ONLY inside `scope()`, which nothing in the
|
|
116
119
|
* framework calls — so `createGateway({ budget: { request: 100 } })` capped no call any app made.
|
|
117
|
-
*
|
|
120
|
+
* A raw `generate` / `stream` outside any scope passes no keys: it has no caller to count.
|
|
118
121
|
*/
|
|
119
|
-
callLedger(): BudgetLedger {
|
|
122
|
+
callLedger(keys: BudgetKeys): BudgetLedger {
|
|
120
123
|
return new BudgetLedger({
|
|
121
124
|
limits: this.config.budget ?? {},
|
|
125
|
+
...(keys.actorKey !== undefined ? { actorKey: keys.actorKey } : {}),
|
|
126
|
+
...(keys.orgKey !== undefined ? { orgKey: keys.orgKey } : {}),
|
|
122
127
|
...(this.config.budgetStore !== undefined ? { store: this.config.budgetStore } : {}),
|
|
123
128
|
});
|
|
124
129
|
}
|
|
125
130
|
|
|
126
|
-
scope<T>(input:
|
|
127
|
-
|
|
128
|
-
limits: this.config.budget ?? {},
|
|
129
|
-
...(input.actorKey !== undefined ? { actorKey: input.actorKey } : {}),
|
|
130
|
-
...(input.orgKey !== undefined ? { orgKey: input.orgKey } : {}),
|
|
131
|
-
...(this.config.budgetStore !== undefined ? { store: this.config.budgetStore } : {}),
|
|
132
|
-
});
|
|
133
|
-
return withBudget(ledger, fn);
|
|
131
|
+
scope<T>(input: BudgetKeys, fn: () => Promise<T>): Promise<T> {
|
|
132
|
+
return withBudget(this.callLedger(input), fn);
|
|
134
133
|
}
|
|
135
134
|
|
|
136
135
|
async spent(): Promise<Money> {
|
|
@@ -153,7 +152,7 @@ class GatewayImpl implements Gateway {
|
|
|
153
152
|
// Reserve against the ESTIMATE before spending anything — tokens AND money, since a
|
|
154
153
|
// cheap-in-tokens call on an expensive model is still a cost cap the app declared.
|
|
155
154
|
// `record` below replaces the estimate with the provider's real counts.
|
|
156
|
-
const ledger = currentBudget() ?? this.callLedger();
|
|
155
|
+
const ledger = currentBudget() ?? this.callLedger({});
|
|
157
156
|
// The estimate is DEBITED here, not merely checked: three concurrent calls under one ledger
|
|
158
157
|
// all read the same `spent()` otherwise, all pass, and all three record against a ceiling
|
|
159
158
|
// only one of them fitted.
|
|
@@ -170,10 +169,20 @@ class GatewayImpl implements Gateway {
|
|
|
170
169
|
await ledger?.record(result.usage, result.cost, reservation);
|
|
171
170
|
// A refusal is not an answer, so it is not cached. Storing one would keep serving a decision
|
|
172
171
|
// the classifier might not make twice, long after the prompt that provoked it was fixed.
|
|
173
|
-
if (result.stopReason !== 'refusal')
|
|
172
|
+
if (result.stopReason !== 'refusal') await this.remember(cacheKey, result);
|
|
173
|
+
return result;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* The cache write, AFTER the call was paid for and recorded. A throw here must not turn that
|
|
178
|
+
* answer into a failure: the caller would retry and pay for it twice. Logged, and the answer kept.
|
|
179
|
+
*/
|
|
180
|
+
private async remember(cacheKey: string, result: GenerateResult): Promise<void> {
|
|
181
|
+
try {
|
|
174
182
|
await this.config.cache?.set(cacheKey, JSON.stringify(result));
|
|
183
|
+
} catch (error) {
|
|
184
|
+
logger.warn('ai.cache.write_failed', { cacheKey, error: renderThrowable(error) });
|
|
175
185
|
}
|
|
176
|
-
return result;
|
|
177
186
|
}
|
|
178
187
|
|
|
179
188
|
async *stream(request: GenerateRequest): AsyncIterable<StreamChunk> {
|
|
@@ -196,7 +205,7 @@ class GatewayImpl implements Gateway {
|
|
|
196
205
|
// `generate`, or reconnects itself and knows what it has already shown.
|
|
197
206
|
const provider = this.providerFor(model);
|
|
198
207
|
|
|
199
|
-
const ledger = currentBudget() ?? this.callLedger();
|
|
208
|
+
const ledger = currentBudget() ?? this.callLedger({});
|
|
200
209
|
const reservation = await ledger?.reserve(estimateSpend(resolved));
|
|
201
210
|
let settled = false;
|
|
202
211
|
try {
|
|
@@ -368,17 +377,20 @@ export function isRetryable(error: unknown): boolean {
|
|
|
368
377
|
|
|
369
378
|
/**
|
|
370
379
|
* Cache key. Every field that changes the answer is in it — a key that ignores `effort` or
|
|
371
|
-
* `system` would serve one prompt's answer for another
|
|
380
|
+
* `system` would serve one prompt's answer for another — and each tool WHOLE: every `llm()` tool
|
|
381
|
+
* is named `respond`, so a key over names alone served an answer shaped for an old `output` schema
|
|
382
|
+
* after the schema changed. Core's `fingerprint`, so key order never splits one request in two and
|
|
383
|
+
* the width is the one every other sharing key in the framework has.
|
|
372
384
|
*/
|
|
373
385
|
export function cacheKeyFor(request: GenerateRequest): string {
|
|
374
|
-
return
|
|
386
|
+
return fingerprint({
|
|
375
387
|
model: request.model,
|
|
376
388
|
system: request.system ?? '',
|
|
377
389
|
messages: request.messages,
|
|
378
390
|
maxTokens: request.maxTokens,
|
|
379
391
|
effort: request.effort ?? 'high',
|
|
380
392
|
thinking: request.thinking ?? 'adaptive',
|
|
381
|
-
tools: request.tools
|
|
393
|
+
tools: request.tools ?? [],
|
|
382
394
|
stop: request.stopSequences ?? [],
|
|
383
395
|
});
|
|
384
396
|
}
|
package/src/hive.ts
CHANGED
|
@@ -20,12 +20,13 @@ import type { Ctx } from '@ultimat3/core';
|
|
|
20
20
|
import { finiteCount, throwIfAborted, withSpan } from '@ultimat3/core';
|
|
21
21
|
import type { AnySchema, InferInput, InferOutput, StandardSchemaV1 } from '@ultimat3/schema';
|
|
22
22
|
import type { BudgetLimits } from './budget';
|
|
23
|
-
import { BudgetLedger, currentBudget, withBudget } from './budget';
|
|
23
|
+
import { BudgetLedger, budgetKeysFor, currentBudget, withBudget } from './budget';
|
|
24
24
|
import { HiveEmptyError } from './hive-errors';
|
|
25
25
|
import { runPool } from './hive-pool';
|
|
26
26
|
import type { HiveMember, HiveMemberError, HiveOutput, HiveResult } from './hive-result';
|
|
27
27
|
import { hiveResultSchema } from './hive-result';
|
|
28
28
|
import type { LlmBudget } from './llm';
|
|
29
|
+
import { installedGateway } from './runtime';
|
|
29
30
|
|
|
30
31
|
/**
|
|
31
32
|
* Members in flight at once when the declaration omits one. Small and deliberately arbitrary — it
|
|
@@ -131,7 +132,14 @@ async function run<
|
|
|
131
132
|
'hive.concurrency': width,
|
|
132
133
|
'hive.on_member_error': def.onMemberError,
|
|
133
134
|
});
|
|
134
|
-
|
|
135
|
+
// Rooted as `llm()` and `agent()` root: the gateway's ceilings, keyed on the caller. An empty
|
|
136
|
+
// root dropped every gateway ceiling for every member. No gateway installed is a hive of plain
|
|
137
|
+
// actions, which spends nothing — a model member is refused `X_AI_GATEWAY_MISSING` on its own.
|
|
138
|
+
const root =
|
|
139
|
+
currentBudget() ??
|
|
140
|
+
installedGateway()?.callLedger(budgetKeysFor(args.ctx.actor)) ??
|
|
141
|
+
new BudgetLedger({ limits: {} });
|
|
142
|
+
const ledger = root.derive(limitsOf(def));
|
|
135
143
|
const result = await withBudget(ledger, () =>
|
|
136
144
|
runPool<InferInput<MIn>, InferOutput<MOut>>({
|
|
137
145
|
inputs,
|
package/src/index.ts
CHANGED
|
@@ -11,14 +11,17 @@ export { describeAgents, resetAgents } from './agent-facts';
|
|
|
11
11
|
export type { AgentJobOptions } from './agent-job';
|
|
12
12
|
export { agentJob } from './agent-job';
|
|
13
13
|
export type {
|
|
14
|
+
BudgetKeys,
|
|
14
15
|
BudgetLedgerInput,
|
|
15
16
|
BudgetLimits,
|
|
16
17
|
BudgetReport,
|
|
17
18
|
BudgetStore,
|
|
19
|
+
BudgetTake,
|
|
18
20
|
SpendEstimate,
|
|
19
21
|
} from './budget';
|
|
20
22
|
export {
|
|
21
23
|
BudgetLedger,
|
|
24
|
+
budgetKeysFor,
|
|
22
25
|
currentBudget,
|
|
23
26
|
estimateSpend,
|
|
24
27
|
MemoryBudgetStore,
|
|
@@ -29,7 +32,6 @@ export {
|
|
|
29
32
|
cosine,
|
|
30
33
|
embedBatched,
|
|
31
34
|
embedOne,
|
|
32
|
-
fnv1a,
|
|
33
35
|
HashEmbedder,
|
|
34
36
|
normalize,
|
|
35
37
|
tokenize,
|
|
@@ -219,5 +221,5 @@ export type {
|
|
|
219
221
|
} from './vector';
|
|
220
222
|
export { fuse, MemoryVectorStore } from './vector';
|
|
221
223
|
export type { VectorScope } from './vector-scope';
|
|
222
|
-
export { NO_TENANT, tenantOf, UNSCOPED } from './vector-scope';
|
|
224
|
+
export { NO_TENANT, tenantOf, UNSCOPED, VectorUnscopedError } from './vector-scope';
|
|
223
225
|
export type { StreamState } from './wire';
|
package/src/llm-cache.ts
CHANGED
|
@@ -16,8 +16,9 @@
|
|
|
16
16
|
*/
|
|
17
17
|
|
|
18
18
|
import type { Ctx } from '@ultimat3/core';
|
|
19
|
+
import { fingerprint } from '@ultimat3/core';
|
|
19
20
|
import { parseDuration } from '@ultimat3/time';
|
|
20
|
-
import { embedOne
|
|
21
|
+
import { embedOne } from './embeddings';
|
|
21
22
|
import { aiEmbedder, semanticCacheFor } from './runtime';
|
|
22
23
|
|
|
23
24
|
/** What a `scope` may decide from. The same pair `vars()` receives, and for the same reason. */
|
|
@@ -119,7 +120,7 @@ export async function openCache<TParsed>(
|
|
|
119
120
|
},
|
|
120
121
|
remember(value: unknown): Promise<void> {
|
|
121
122
|
return store.remember(
|
|
122
|
-
`${args.prompt.hash}:${
|
|
123
|
+
`${args.prompt.hash}:${fingerprint(args.rendered)}`,
|
|
123
124
|
embedding,
|
|
124
125
|
value,
|
|
125
126
|
ttlMs === undefined ? {} : { ttlMs },
|
package/src/llm.ts
CHANGED
|
@@ -27,9 +27,9 @@ import type { Ctx, Span, SpanAttributes } from '@ultimat3/core';
|
|
|
27
27
|
import { finiteCount, withSpan } from '@ultimat3/core';
|
|
28
28
|
import type { Money } from '@ultimat3/money';
|
|
29
29
|
import type { InferInput, InferOutput, StandardSchemaV1 } from '@ultimat3/schema';
|
|
30
|
-
import { formatIssues,
|
|
30
|
+
import { formatIssues, validateAsync } from '@ultimat3/schema';
|
|
31
31
|
import type { BudgetLimits } from './budget';
|
|
32
|
-
import {
|
|
32
|
+
import { budgetKeysFor, currentBudget, withBudget } from './budget';
|
|
33
33
|
import {
|
|
34
34
|
LlmOutputInvalidError,
|
|
35
35
|
LlmRefusedError,
|
|
@@ -47,15 +47,9 @@ import type { Prompt, PromptVars } from './prompt';
|
|
|
47
47
|
import type { AiMessage, GenerateRequest, GenerateResult } from './provider';
|
|
48
48
|
import { isTruncated } from './provider';
|
|
49
49
|
import { assertNoSecrets } from './redaction';
|
|
50
|
+
import type { Respond } from './respond';
|
|
51
|
+
import { parseJsonish, RESPOND, respondFor } from './respond';
|
|
50
52
|
import { aiGateway, aiRedactor } from './runtime';
|
|
51
|
-
import type { LlmTool } from './tools';
|
|
52
|
-
|
|
53
|
-
/**
|
|
54
|
-
* The tool the model answers through. One name, so the reader never has to guess — and shared
|
|
55
|
-
* with `agent()`, which offers the app's tools alongside it and needs the same name to tell an
|
|
56
|
-
* answer from a tool call.
|
|
57
|
-
*/
|
|
58
|
-
export const RESPOND = 'respond';
|
|
59
53
|
|
|
60
54
|
/** Two attempts total: the answer, then one repair turn. See `LlmOutputInvalidError`. */
|
|
61
55
|
const ATTEMPTS = 2;
|
|
@@ -138,8 +132,8 @@ export function llm<
|
|
|
138
132
|
TOutput extends StandardSchemaV1,
|
|
139
133
|
V extends PromptVars,
|
|
140
134
|
>(def: LlmDef<TInput, TOutput, V>): LlmAction<TInput, TOutput> {
|
|
141
|
-
const respond =
|
|
142
|
-
// Screened at DECLARATION, beside `
|
|
135
|
+
const respond = respondFor(def.output);
|
|
136
|
+
// Screened at DECLARATION, beside `respondFor`'s own refusal and for the same reason: an
|
|
143
137
|
// `llm()` is evaluated at module scope, so a bound that is not one fails the boot rather than
|
|
144
138
|
// the first request. It is the read at `generate` below that makes it urgent — `maxTokens`
|
|
145
139
|
// becomes the pre-flight ESTIMATE, a `NaN` estimate passes every `want > remaining` check, and
|
|
@@ -190,7 +184,7 @@ async function generate<
|
|
|
190
184
|
V extends PromptVars,
|
|
191
185
|
>(
|
|
192
186
|
def: LlmDef<TInput, TOutput, V>,
|
|
193
|
-
respond:
|
|
187
|
+
respond: Respond,
|
|
194
188
|
args: { readonly input: InferOutput<TInput>; readonly ctx: Ctx },
|
|
195
189
|
): Promise<InferOutput<TOutput>> {
|
|
196
190
|
const { prompt } = def;
|
|
@@ -250,7 +244,7 @@ async function generate<
|
|
|
250
244
|
// the provider call in flight, billed and unread, and the repair turn bought a SECOND one.
|
|
251
245
|
signal: args.ctx.signal,
|
|
252
246
|
};
|
|
253
|
-
const request: GenerateRequest = { ...base, tools: [respond] };
|
|
247
|
+
const request: GenerateRequest = { ...base, tools: [respond.tool] };
|
|
254
248
|
|
|
255
249
|
// A ledger derived from the ambient one, so a per-call budget can only TIGHTEN the actor
|
|
256
250
|
// and org ceilings this call runs inside, never widen them. The gateway reserves against
|
|
@@ -259,11 +253,10 @@ async function generate<
|
|
|
259
253
|
const limits = limitsOf(def.budget);
|
|
260
254
|
// Rooted in the GATEWAY's own ceilings when no scope is open — an empty root ignored them.
|
|
261
255
|
const gateway = aiGateway(name);
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
).derive(limits);
|
|
256
|
+
// Keyed on the CALLER, so the gateway's `actor` / `org` ceilings count this call against them.
|
|
257
|
+
const ledger = (currentBudget() ?? gateway.callLedger(budgetKeysFor(args.ctx.actor))).derive(
|
|
258
|
+
limits,
|
|
259
|
+
);
|
|
267
260
|
const sink = currentLlmSink();
|
|
268
261
|
|
|
269
262
|
return withBudget(ledger, async () => {
|
|
@@ -292,7 +285,7 @@ async function generate<
|
|
|
292
285
|
explanation: result.stopDetails?.explanation,
|
|
293
286
|
});
|
|
294
287
|
}
|
|
295
|
-
const parsed = await validateAsync(def.output,
|
|
288
|
+
const parsed = await validateAsync(def.output, respond.read(result));
|
|
296
289
|
if (parsed.issues === undefined) {
|
|
297
290
|
await cache?.remember(parsed.value);
|
|
298
291
|
return parsed.value;
|
|
@@ -403,21 +396,6 @@ function limitsOf(budget: LlmBudget | undefined): BudgetLimits {
|
|
|
403
396
|
};
|
|
404
397
|
}
|
|
405
398
|
|
|
406
|
-
/**
|
|
407
|
-
* The output schema as the only tool the model may answer through — the spec's "structured
|
|
408
|
-
* output drives tool use". `toMcpInputSchema` is the same projection an MCP client sees, so
|
|
409
|
-
* a model and an agent are shown one shape, and a schema it cannot express throws HERE, at
|
|
410
|
-
* declaration time, rather than degrading into a permissive node the model cannot satisfy.
|
|
411
|
-
*/
|
|
412
|
-
export function respondToolFor(output: StandardSchemaV1): LlmTool {
|
|
413
|
-
return {
|
|
414
|
-
name: RESPOND,
|
|
415
|
-
description: 'Return the result. Call this exactly once; do not answer in prose.',
|
|
416
|
-
input_schema: toMcpInputSchema(output),
|
|
417
|
-
strict: true,
|
|
418
|
-
};
|
|
419
|
-
}
|
|
420
|
-
|
|
421
399
|
/**
|
|
422
400
|
* What the model answered, as text the Messages API will accept — or nothing.
|
|
423
401
|
*
|
|
@@ -436,23 +414,3 @@ function assistantEcho(result: GenerateResult): string | undefined {
|
|
|
436
414
|
const replayed = JSON.stringify(call.input);
|
|
437
415
|
return replayed === undefined || replayed === '' ? undefined : replayed;
|
|
438
416
|
}
|
|
439
|
-
|
|
440
|
-
/**
|
|
441
|
-
* The tool call if the model made one, otherwise the text parsed as JSON — a model that
|
|
442
|
-
* answers in prose is a schema failure, not a crash, so it flows into the repair turn.
|
|
443
|
-
*/
|
|
444
|
-
export function structuredOutputOf(result: GenerateResult): unknown {
|
|
445
|
-
const call = result.toolCalls.find((c) => c.name === RESPOND);
|
|
446
|
-
if (call !== undefined) return call.input;
|
|
447
|
-
return parseJsonish(result.text);
|
|
448
|
-
}
|
|
449
|
-
|
|
450
|
-
function parseJsonish(text: string): unknown {
|
|
451
|
-
const fenced = /```(?:json)?\s*([\s\S]*?)```/.exec(text);
|
|
452
|
-
const body = (fenced?.[1] ?? text).trim();
|
|
453
|
-
try {
|
|
454
|
-
return JSON.parse(body);
|
|
455
|
-
} catch {
|
|
456
|
-
return undefined;
|
|
457
|
-
}
|
|
458
|
-
}
|
package/src/openai-wire.ts
CHANGED
|
@@ -62,9 +62,14 @@ const ERROR_STATUS: ReadonlyMap<string, number> = new Map(
|
|
|
62
62
|
}),
|
|
63
63
|
);
|
|
64
64
|
|
|
65
|
-
/**
|
|
65
|
+
/**
|
|
66
|
+
* The finish reason, or `undefined` for a non-string (`null` on a delta means "still going").
|
|
67
|
+
* A string this table does not know FAILS CLOSED as `max_tokens` — `wire.ts`'s `parseStopReason`
|
|
68
|
+
* rule, so one unknown reason reads the same on both formats: a provider's new reason is far
|
|
69
|
+
* likelier to be a way of stopping early than of finishing.
|
|
70
|
+
*/
|
|
66
71
|
export function parseFinishReason(raw: unknown): StopReason | undefined {
|
|
67
|
-
return typeof raw === 'string' ? FINISH_REASONS.get(raw) : undefined;
|
|
72
|
+
return typeof raw === 'string' ? (FINISH_REASONS.get(raw) ?? 'max_tokens') : undefined;
|
|
68
73
|
}
|
|
69
74
|
|
|
70
75
|
/**
|
package/src/pg-vector.ts
CHANGED
|
@@ -24,7 +24,7 @@ import type {
|
|
|
24
24
|
VectorRecord,
|
|
25
25
|
VectorStore,
|
|
26
26
|
} from './vector';
|
|
27
|
-
import { narrowScope, tenantOf,
|
|
27
|
+
import { assertTenantRead, narrowScope, tenantOf, UNBOUND, type VectorScope } from './vector-scope';
|
|
28
28
|
|
|
29
29
|
export interface PgVectorStoreInput {
|
|
30
30
|
/** The table. Also the store's name in errors, and the stem of every index name. */
|
|
@@ -60,7 +60,7 @@ export class PgVectorStore implements VectorStore {
|
|
|
60
60
|
this.input = input;
|
|
61
61
|
this.name = input.name;
|
|
62
62
|
this.dimension = input.dimension;
|
|
63
|
-
this.scope = input.scope ??
|
|
63
|
+
this.scope = input.scope ?? UNBOUND;
|
|
64
64
|
this.target = {
|
|
65
65
|
table: input.name,
|
|
66
66
|
dimension: input.dimension,
|
|
@@ -75,8 +75,8 @@ export class PgVectorStore implements VectorStore {
|
|
|
75
75
|
}
|
|
76
76
|
|
|
77
77
|
/**
|
|
78
|
-
* A view of the same table through a narrower envelope.
|
|
79
|
-
* path; a request handler derives from it and can never widen back out.
|
|
78
|
+
* A view of the same table through a narrower envelope. A store opened with `scope: UNSCOPED`
|
|
79
|
+
* is the backfill path; a request handler derives from it and can never widen back out.
|
|
80
80
|
*/
|
|
81
81
|
scoped(scope: VectorScope): PgVectorStore {
|
|
82
82
|
return new PgVectorStore({
|
|
@@ -111,6 +111,7 @@ export class PgVectorStore implements VectorStore {
|
|
|
111
111
|
k: number,
|
|
112
112
|
filter?: MetadataFilter,
|
|
113
113
|
): Promise<readonly SearchHit[]> {
|
|
114
|
+
assertTenantRead(this.name, this.scope);
|
|
114
115
|
this.assertDimension(vector.length);
|
|
115
116
|
// `k` is the statement's `limit` and reaches Postgres as a bound parameter, so a `NaN` is the
|
|
116
117
|
// database's error to report rather than this store's — and a fractional one is an error there
|
|
@@ -125,12 +126,14 @@ export class PgVectorStore implements VectorStore {
|
|
|
125
126
|
k: number,
|
|
126
127
|
filter?: MetadataFilter,
|
|
127
128
|
): Promise<readonly SearchHit[]> {
|
|
129
|
+
assertTenantRead(this.name, this.scope);
|
|
128
130
|
return this.run(
|
|
129
131
|
textSql(this.target, query, { scope: this.scope, filter, k: finiteCount(HYBRID, 'k', k) }),
|
|
130
132
|
);
|
|
131
133
|
}
|
|
132
134
|
|
|
133
135
|
async hybrid(input: HybridSearchInput): Promise<readonly SearchHit[]> {
|
|
136
|
+
assertTenantRead(this.name, this.scope);
|
|
134
137
|
this.assertDimension(input.vector.length);
|
|
135
138
|
// The same three bounds `MemoryVectorStore.hybrid` screens, and they have to be screened in
|
|
136
139
|
// BOTH stores: `rrfK` lands in `1.0 / (rrfK + rank)`, Postgres has a float8 `NaN`, and it
|
package/src/provider.ts
CHANGED
|
@@ -463,14 +463,17 @@ function lastUserMessage(messages: readonly AiMessage[]): string {
|
|
|
463
463
|
}
|
|
464
464
|
|
|
465
465
|
/**
|
|
466
|
-
* ~4 characters per token. Deliberately an ESTIMATE and never used for billing — the
|
|
467
|
-
*
|
|
468
|
-
*
|
|
466
|
+
* ~4 characters per token. Deliberately an ESTIMATE and never used for billing — the gateway's
|
|
467
|
+
* pre-flight budget check needs a number before the call exists, and `usage` replaces it after.
|
|
468
|
+
* The completion is clamped to what the model can write, exactly as `estimateCost` clamps it.
|
|
469
469
|
*/
|
|
470
470
|
export function estimateTokens(request: GenerateRequest): number {
|
|
471
|
-
return estimateInputTokens(request) + request
|
|
471
|
+
return estimateInputTokens(request) + completionCeiling(request);
|
|
472
472
|
}
|
|
473
473
|
|
|
474
|
+
const completionCeiling = (request: GenerateRequest): number =>
|
|
475
|
+
Math.min(request.maxTokens, modelSpec(request.model ?? DEFAULT_MODEL).maxOutput);
|
|
476
|
+
|
|
474
477
|
/** The prompt half alone — what the provider bills at the input rate. */
|
|
475
478
|
export function estimateInputTokens(request: GenerateRequest): number {
|
|
476
479
|
const body = request.messages.map(messageText).join(' ');
|
|
@@ -483,10 +486,9 @@ export function estimateInputTokens(request: GenerateRequest): number {
|
|
|
483
486
|
* optimistic estimate is a ceiling one long completion walks through.
|
|
484
487
|
*/
|
|
485
488
|
export function estimateCost(request: GenerateRequest): Money {
|
|
486
|
-
|
|
487
|
-
return costOf(model, {
|
|
489
|
+
return costOf(request.model ?? DEFAULT_MODEL, {
|
|
488
490
|
inputTokens: estimateInputTokens(request),
|
|
489
|
-
outputTokens:
|
|
491
|
+
outputTokens: completionCeiling(request),
|
|
490
492
|
cacheReadTokens: 0,
|
|
491
493
|
cacheWriteTokens: 0,
|
|
492
494
|
});
|
package/src/rag.ts
CHANGED
|
@@ -56,7 +56,9 @@ export function chunk(input: ChunkInput): readonly Chunk[] {
|
|
|
56
56
|
id: `${input.id}#${chunks.length}`,
|
|
57
57
|
text,
|
|
58
58
|
tokens: estimateChunkTokens(text),
|
|
59
|
-
|
|
59
|
+
// Written AFTER the spread: `source` is the framework's stamp, and `indexDocument` prunes by
|
|
60
|
+
// it — a caller's own `source` replaced it, and a re-index pruned the wrong rows or none.
|
|
61
|
+
metadata: { ...(input.metadata ?? {}), source: input.id },
|
|
60
62
|
});
|
|
61
63
|
}
|
|
62
64
|
// Carry the tail forward as the overlap for the next chunk — at most every unit BUT THE
|
|
@@ -78,6 +80,11 @@ export function chunk(input: ChunkInput): readonly Chunk[] {
|
|
|
78
80
|
for (const unit of units) {
|
|
79
81
|
const unitTokens = estimateChunkTokens(unit);
|
|
80
82
|
if (tokens + unitTokens > size && buffer.length > 0) flush();
|
|
83
|
+
// The carried overlap yields to the unit, oldest first: a carry the unit does not fit beside
|
|
84
|
+
// made a chunk past `size` — the one promise this function makes.
|
|
85
|
+
while (buffer.length > 0 && tokens + unitTokens > size) {
|
|
86
|
+
tokens -= estimateChunkTokens(buffer.shift() ?? '');
|
|
87
|
+
}
|
|
81
88
|
buffer.push(unit);
|
|
82
89
|
tokens += unitTokens;
|
|
83
90
|
}
|
|
@@ -158,7 +165,7 @@ function cutToBudget(run: string, size: number): readonly string[] {
|
|
|
158
165
|
* Index a document: chunk, embed, upsert, prune. One call so no step is skipped by accident. The
|
|
159
166
|
* prune is what re-indexing a SHORTER document needs: chunk ids are `<doc>#<n>`, so the upsert
|
|
160
167
|
* 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.
|
|
168
|
+
* document is never absent from a search in between.
|
|
162
169
|
*/
|
|
163
170
|
export async function indexDocument(input: {
|
|
164
171
|
readonly store: VectorStore;
|
|
@@ -182,7 +189,7 @@ export async function indexDocument(input: {
|
|
|
182
189
|
})),
|
|
183
190
|
);
|
|
184
191
|
// `source` is the chunker's own stamp (`chunk()`), so only THIS document's rows are touched.
|
|
185
|
-
await input.store.prune
|
|
192
|
+
await input.store.prune(
|
|
186
193
|
{ source: input.document.id },
|
|
187
194
|
chunks.map((c) => c.id),
|
|
188
195
|
);
|