@ultimat3/ai 29.4.0 → 30.1.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 +63 -90
- package/README.md +125 -8
- package/package.json +23 -11
- package/src/agent-checkpoint.ts +44 -0
- package/src/agent-compact-def.ts +11 -0
- package/src/agent-compact-measure.ts +38 -0
- package/src/agent-compact-request.ts +100 -0
- package/src/agent-compact-text.ts +95 -0
- package/src/agent-compact.ts +75 -50
- package/src/agent-continue.ts +15 -3
- package/src/agent-def.ts +9 -0
- package/src/agent-errors.ts +12 -2
- package/src/agent-facts.ts +8 -0
- package/src/agent-send.ts +82 -0
- package/src/agent-stream.ts +76 -0
- package/src/agent.ts +71 -21
- package/src/answer-errors.ts +1 -1
- package/src/anthropic-provider.ts +291 -0
- package/src/content-errors.ts +1 -1
- package/src/context-overflow.ts +86 -0
- package/src/error-codes.ts +95 -0
- package/src/errors-retry.ts +8 -4
- package/src/errors.ts +6 -87
- package/src/eval-agent.ts +100 -0
- package/src/eval-errors.ts +31 -5
- package/src/evals.ts +125 -45
- package/src/gateway-providers.ts +215 -0
- package/src/gateway.ts +63 -153
- package/src/hive-errors.ts +1 -1
- package/src/in-process-tool.ts +20 -11
- package/src/index.ts +31 -7
- package/src/llm.ts +39 -8
- package/src/model-resolve.ts +7 -1
- package/src/openai-body.ts +12 -5
- package/src/openai-provider.ts +28 -8
- package/src/openai-wire.ts +11 -7
- package/src/prompt-cache-rows.ts +82 -0
- package/src/prompt-cache.ts +24 -2
- package/src/provider.ts +15 -274
- package/src/remote-embedder.ts +38 -11
- package/src/run-meter.ts +40 -2
- package/src/runtime.ts +38 -3
- package/src/scorers.ts +35 -1
- package/src/scripted-provider.ts +146 -0
- package/src/tools-gate.ts +46 -0
- package/src/tools.ts +101 -27
- package/src/wire.ts +12 -4
package/src/gateway.ts
CHANGED
|
@@ -4,17 +4,8 @@
|
|
|
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 type { Random } from '@ultimat3/core';
|
|
8
|
-
import {
|
|
9
|
-
backoffDelay,
|
|
10
|
-
fingerprint,
|
|
11
|
-
finiteCount,
|
|
12
|
-
isRetryableStatus,
|
|
13
|
-
isUltimateError,
|
|
14
|
-
logger,
|
|
15
|
-
renderThrowable,
|
|
16
|
-
stringField,
|
|
17
|
-
} from '@ultimat3/core';
|
|
7
|
+
import type { Breaker, Random } from '@ultimat3/core';
|
|
8
|
+
import { fingerprint, finiteCount, logger, renderThrowable } from '@ultimat3/core';
|
|
18
9
|
import type { Money } from '@ultimat3/money';
|
|
19
10
|
import { billedOf } from './billed';
|
|
20
11
|
import type { BudgetKeys, BudgetLimits, BudgetReservation, BudgetStore } from './budget';
|
|
@@ -25,11 +16,18 @@ import {
|
|
|
25
16
|
estimateSpend,
|
|
26
17
|
withBudget,
|
|
27
18
|
} from './budget';
|
|
28
|
-
import {
|
|
19
|
+
import type { GatewayBreakerOption } from './gateway-providers';
|
|
20
|
+
import {
|
|
21
|
+
admittedProvider,
|
|
22
|
+
attemptProviders,
|
|
23
|
+
providerBreakers,
|
|
24
|
+
streamVerdict,
|
|
25
|
+
} from './gateway-providers';
|
|
29
26
|
import { resolveModel } from './model-resolve';
|
|
30
27
|
import type { ModelId } from './models';
|
|
31
28
|
import type { GenerateRequest, GenerateResult, Provider, StreamChunk } from './provider';
|
|
32
29
|
import type { SamplingWire } from './sampling';
|
|
30
|
+
import { ZERO_USAGE } from './wire';
|
|
33
31
|
|
|
34
32
|
/**
|
|
35
33
|
* Response cache. Structurally satisfied by `@ultimat3/cache`'s memo/LRU tiers; declared as
|
|
@@ -61,8 +59,8 @@ export const DEFAULT_GATEWAY_RETRY: RetryPolicy = {
|
|
|
61
59
|
maxDelayMs: 8_000,
|
|
62
60
|
};
|
|
63
61
|
|
|
64
|
-
|
|
65
|
-
|
|
62
|
+
export type { GatewayBreakerOption } from './gateway-providers';
|
|
63
|
+
export { backoffMs, DEFAULT_GATEWAY_BREAKER, isRetryable } from './gateway-providers';
|
|
66
64
|
|
|
67
65
|
export interface ProviderGatewayInput {
|
|
68
66
|
/** Tried in order for a given model. First provider that lists the model wins. */
|
|
@@ -81,6 +79,12 @@ export interface ProviderGatewayInput {
|
|
|
81
79
|
* green. Production never passes one.
|
|
82
80
|
*/
|
|
83
81
|
readonly random?: Random | undefined;
|
|
82
|
+
/**
|
|
83
|
+
* One breaker per provider, on by default (`{ failures: 5, cooldownMs: 30_000 }`): a provider
|
|
84
|
+
* whose retries ran out five passes running is skipped — no call, no sleep — until one probe
|
|
85
|
+
* after the cooldown answers. Per process. `(provider) => breaker({ … })` to choose your own, `'off'` declines.
|
|
86
|
+
*/
|
|
87
|
+
readonly breaker?: GatewayBreakerOption | undefined;
|
|
84
88
|
}
|
|
85
89
|
|
|
86
90
|
export interface Gateway {
|
|
@@ -124,6 +128,7 @@ class GatewayImpl implements Gateway {
|
|
|
124
128
|
private readonly sleep: (ms: number) => Promise<void>;
|
|
125
129
|
/** Left `undefined` rather than defaulted, so `backoffDelay` owns the one fallback to `Math.random`. */
|
|
126
130
|
private readonly random: Random | undefined;
|
|
131
|
+
private readonly gateOf: (provider: Provider) => Breaker | undefined;
|
|
127
132
|
|
|
128
133
|
get defaultModel(): ModelId | undefined {
|
|
129
134
|
return this.config.defaultModel;
|
|
@@ -146,6 +151,7 @@ class GatewayImpl implements Gateway {
|
|
|
146
151
|
if (config.budget !== undefined) assertFiniteLimits(config.budget, 'gateway', 'budget.');
|
|
147
152
|
this.sleep = config.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
|
|
148
153
|
this.random = config.random;
|
|
154
|
+
this.gateOf = providerBreakers(config.breaker);
|
|
149
155
|
}
|
|
150
156
|
|
|
151
157
|
/**
|
|
@@ -179,8 +185,9 @@ class GatewayImpl implements Gateway {
|
|
|
179
185
|
const cacheKey = promptCacheKey(resolved);
|
|
180
186
|
const cached = await this.config.cache?.get(cacheKey);
|
|
181
187
|
if (cached !== undefined) {
|
|
182
|
-
// A cache hit costs nothing, so it is deliberately NOT debited from the budget
|
|
183
|
-
|
|
188
|
+
// A cache hit costs nothing, so it is deliberately NOT debited from the budget, and says so:
|
|
189
|
+
// carrying the stored figures, a meter summing the run billed it a second time.
|
|
190
|
+
return freeOf(JSON.parse(cached) as GenerateResult);
|
|
184
191
|
}
|
|
185
192
|
|
|
186
193
|
// Reserve against the ESTIMATE before spending anything — tokens AND money, since a
|
|
@@ -196,7 +203,15 @@ class GatewayImpl implements Gateway {
|
|
|
196
203
|
|
|
197
204
|
let result: GenerateResult;
|
|
198
205
|
try {
|
|
199
|
-
result = await
|
|
206
|
+
result = await attemptProviders({
|
|
207
|
+
model,
|
|
208
|
+
candidates: this.config.providers.filter((p) => p.models.includes(model)),
|
|
209
|
+
retry: this.retry,
|
|
210
|
+
sleep: this.sleep,
|
|
211
|
+
random: this.random,
|
|
212
|
+
gateOf: this.gateOf,
|
|
213
|
+
call: (provider) => provider.generate(resolved),
|
|
214
|
+
});
|
|
200
215
|
} catch (error) {
|
|
201
216
|
// A call that never landed must not go on holding its reservation — and one that DID land
|
|
202
217
|
// and still failed (`billedOf`: the provider answered with something unusable) is recorded
|
|
@@ -251,15 +266,28 @@ class GatewayImpl implements Gateway {
|
|
|
251
266
|
// single provider serving this model and that call stands or throws; `attempt`'s backoff and
|
|
252
267
|
// its fallback across providers belong to `generate` alone. A caller that wants either uses
|
|
253
268
|
// `generate`, or reconnects itself and knows what it has already shown.
|
|
254
|
-
const provider = this.
|
|
269
|
+
const { provider, ticket } = admittedProvider(model, this.config.providers, this.gateOf);
|
|
270
|
+
const gate = this.gateOf(provider);
|
|
271
|
+
let answered = false;
|
|
255
272
|
|
|
256
273
|
const ledger = currentBudget() ?? this.callLedger({});
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
274
|
+
let reservation: BudgetReservation | undefined;
|
|
275
|
+
try {
|
|
276
|
+
reservation = await ledger?.reserve(estimateSpend(resolved, this.marksPromptCache(model)));
|
|
277
|
+
} catch (error) {
|
|
278
|
+
// A refused reservation sent nothing: the half-open probe `admittedProvider` took goes back,
|
|
279
|
+
// or the next healthy call would wait out a whole extra cooldown.
|
|
280
|
+
gate?.released(ticket);
|
|
281
|
+
throw error;
|
|
282
|
+
}
|
|
260
283
|
let settled = false;
|
|
261
284
|
try {
|
|
262
285
|
for await (const chunk of provider.stream(resolved)) {
|
|
286
|
+
// The first chunk is the provider's verdict for its breaker: it answered.
|
|
287
|
+
if (!answered) {
|
|
288
|
+
answered = true;
|
|
289
|
+
streamVerdict(gate, ticket, undefined);
|
|
290
|
+
}
|
|
263
291
|
if (chunk.type !== 'done') {
|
|
264
292
|
yield chunk;
|
|
265
293
|
continue;
|
|
@@ -272,6 +300,10 @@ class GatewayImpl implements Gateway {
|
|
|
272
300
|
yield { type: 'done', result: { ...chunk.result, provider: provider.name } };
|
|
273
301
|
}
|
|
274
302
|
} catch (error) {
|
|
303
|
+
if (!answered) {
|
|
304
|
+
answered = true;
|
|
305
|
+
streamVerdict(gate, ticket, error);
|
|
306
|
+
}
|
|
275
307
|
// Thrown after the provider reported usage: the bill is recorded, exactly as `generate` does.
|
|
276
308
|
// Only when nothing settled yet — a provider that throws AFTER its `done` chunk was already
|
|
277
309
|
// recorded, and releasing again would credit the ledger a second time.
|
|
@@ -284,6 +316,8 @@ class GatewayImpl implements Gateway {
|
|
|
284
316
|
// A stream its consumer abandoned never reached `done` — so nothing reconciled the
|
|
285
317
|
// reservation and it would hold the ceiling for the rest of the window.
|
|
286
318
|
if (!settled) await ledger?.release(reservation);
|
|
319
|
+
// Abandoned before a chunk: the probe goes back, counting nothing.
|
|
320
|
+
if (!answered) gate?.released(ticket);
|
|
287
321
|
}
|
|
288
322
|
}
|
|
289
323
|
|
|
@@ -299,73 +333,6 @@ class GatewayImpl implements Gateway {
|
|
|
299
333
|
(p) => p.models.includes(model) && p.marksPromptCache?.(model) === true,
|
|
300
334
|
);
|
|
301
335
|
}
|
|
302
|
-
|
|
303
|
-
private providerFor(model: ModelId): Provider {
|
|
304
|
-
const provider = this.config.providers.find((p) => p.models.includes(model));
|
|
305
|
-
if (provider === undefined) {
|
|
306
|
-
throw new AiProviderUnavailableError({
|
|
307
|
-
model,
|
|
308
|
-
attempts: this.config.providers.map((p) => `${p.name}: does not serve ${model}`),
|
|
309
|
-
unserved: true,
|
|
310
|
-
});
|
|
311
|
-
}
|
|
312
|
-
return provider;
|
|
313
|
-
}
|
|
314
|
-
|
|
315
|
-
/**
|
|
316
|
-
* Try every provider that serves `model`, retrying each on a retryable failure with
|
|
317
|
-
* exponential backoff plus full jitter (jitter matters: synchronised retries from N
|
|
318
|
-
* workers reproduce the rate limit they are backing off from).
|
|
319
|
-
*
|
|
320
|
-
* Fallback here is across PROVIDERS serving one model, never across models — a silent model
|
|
321
|
-
* swap changes what answered, what it cost and which eval baseline the answer belongs to. The
|
|
322
|
-
* provider that did answer is stamped onto the result, so the fallback that DOES exist reaches
|
|
323
|
-
* the span instead of being invisible.
|
|
324
|
-
*/
|
|
325
|
-
private async attempt(
|
|
326
|
-
model: ModelId,
|
|
327
|
-
call: (provider: Provider) => Promise<GenerateResult>,
|
|
328
|
-
): Promise<GenerateResult> {
|
|
329
|
-
const candidates = this.config.providers.filter((p) => p.models.includes(model));
|
|
330
|
-
const failures: string[] = [];
|
|
331
|
-
if (candidates.length === 0) {
|
|
332
|
-
throw new AiProviderUnavailableError({
|
|
333
|
-
model,
|
|
334
|
-
attempts: [`no provider serves ${model}`],
|
|
335
|
-
unserved: true,
|
|
336
|
-
});
|
|
337
|
-
}
|
|
338
|
-
|
|
339
|
-
for (const provider of candidates) {
|
|
340
|
-
for (let attempt = 1; attempt <= this.retry.attempts; attempt += 1) {
|
|
341
|
-
try {
|
|
342
|
-
return { ...(await call(provider)), provider: provider.name };
|
|
343
|
-
} catch (error) {
|
|
344
|
-
// A coded refusal that is NOT `X_AI_PROVIDER_UNAVAILABLE` reaches the caller verbatim.
|
|
345
|
-
// Those are raised locally, before the socket opens — a missing credential, a control
|
|
346
|
-
// the model does not accept — so the same rejection is waiting on every provider and
|
|
347
|
-
// every attempt, which is the reason a 400 is not retried three lines below. Collecting
|
|
348
|
-
// one into `X_AI_PROVIDER_UNAVAILABLE` discards its runnable `fix:` and answers the same
|
|
349
|
-
// failure a different way from `stream`, which does not route through here at all.
|
|
350
|
-
// A transport failure keeps the old path: `AiTransportError` IS
|
|
351
|
-
// `X_AI_PROVIDER_UNAVAILABLE`, so "provider one 503'd, provider two timed out" still
|
|
352
|
-
// collects across the candidates. `stringField` rather than `error.code`, because the
|
|
353
|
-
// value came from an app's `Provider` and a property read on it can trap.
|
|
354
|
-
if (isUltimateError(error) && stringField(error, 'code') !== PROVIDER_UNAVAILABLE) {
|
|
355
|
-
throw error;
|
|
356
|
-
}
|
|
357
|
-
// `renderThrowable`, never `error.message` or `String(error)`: this line becomes the
|
|
358
|
-
// `cause` of `X_AI_PROVIDER_UNAVAILABLE`, and a renderer that throws replaces the coded
|
|
359
|
-
// refusal with a `TypeError` nothing downstream can catch by code. It bounds the text
|
|
360
|
-
// too — a provider's 1MB body is not a cause.
|
|
361
|
-
failures.push(`${provider.name}#${attempt}: ${renderThrowable(error)}`);
|
|
362
|
-
if (!isRetryable(error) || attempt === this.retry.attempts) break;
|
|
363
|
-
await this.sleep(backoffMs(this.retry, attempt, this.random));
|
|
364
|
-
}
|
|
365
|
-
}
|
|
366
|
-
}
|
|
367
|
-
throw new AiProviderUnavailableError({ model, attempts: failures });
|
|
368
|
-
}
|
|
369
336
|
}
|
|
370
337
|
|
|
371
338
|
/** A failed call's reservation: reconciled against its bill when the failure carries one, else given back. */
|
|
@@ -379,6 +346,14 @@ async function settleFailure(
|
|
|
379
346
|
await ledger?.record(billed.usage, billed.cost, reservation);
|
|
380
347
|
}
|
|
381
348
|
|
|
349
|
+
/** The stored answer as a hit delivers it: no token spent, no money, in the currency it named. */
|
|
350
|
+
const freeOf = (stored: GenerateResult): GenerateResult => ({
|
|
351
|
+
...stored,
|
|
352
|
+
usage: ZERO_USAGE,
|
|
353
|
+
cost: { ...stored.cost, minor: 0 },
|
|
354
|
+
...(stored.costExact === undefined ? {} : { costExact: { ...stored.costExact, minor: 0 } }),
|
|
355
|
+
});
|
|
356
|
+
|
|
382
357
|
/**
|
|
383
358
|
* The request's completion ceiling, screened at the one seam every model call in an app passes
|
|
384
359
|
* through — `llm()`, `agent()`, an eval, a judge and a hand-built `generate()` alike.
|
|
@@ -394,71 +369,6 @@ function ceilingOf(request: GenerateRequest): number {
|
|
|
394
369
|
return finiteCount('the AI gateway', 'maxTokens', request.maxTokens, 1);
|
|
395
370
|
}
|
|
396
371
|
|
|
397
|
-
/**
|
|
398
|
-
* Full jitter: a uniform pick from [0, exponential], capped BEFORE the roll.
|
|
399
|
-
*
|
|
400
|
-
* The arithmetic is core's `backoffDelay` — this is the mapping from the gateway's own field names
|
|
401
|
-
* onto it, and nothing else. Two things came with the delegation and neither is cosmetic: the
|
|
402
|
-
* result is ROUNDED where this floored it (a shift of at most 1ms, and the same rounding
|
|
403
|
-
* `@ultimat3/jobs` and `@ultimat3/realtime` already use), and a policy carrying a `NaN` — which is
|
|
404
|
-
* what `Number(process.env.…)` answers for an unset variable — is REFUSED rather than clamped.
|
|
405
|
-
* This paragraph said "waits 0" until 2026-08-26, which was the safe answer to the wrong question
|
|
406
|
-
* and had already stopped being true: a schedule of zeroes still spins, it just spins on purpose,
|
|
407
|
-
* so core's `backoffDelay` refuses a non-finite bound before it clamps and this gateway inherits
|
|
408
|
-
* the refusal with the curve. `gateway-backoff.test.ts` pins both halves — the refusal, and the
|
|
409
|
-
* negative base that IS still clamped to zero.
|
|
410
|
-
*/
|
|
411
|
-
export function backoffMs(policy: RetryPolicy, attempt: number, random?: Random): number {
|
|
412
|
-
return backoffDelay({
|
|
413
|
-
attempt,
|
|
414
|
-
base: policy.baseDelayMs,
|
|
415
|
-
max: policy.maxDelayMs,
|
|
416
|
-
curve: 'exponential',
|
|
417
|
-
jitter: 'full',
|
|
418
|
-
random,
|
|
419
|
-
});
|
|
420
|
-
}
|
|
421
|
-
|
|
422
|
-
/**
|
|
423
|
-
* Retryable = the request was well formed and the provider was momentarily unable. A 400 is
|
|
424
|
-
* never retried: the same body produces the same rejection and only burns the budget.
|
|
425
|
-
*
|
|
426
|
-
* The status half is core's `isRetryableStatus`, which is WIDER than the `429 || >= 500` this
|
|
427
|
-
* gateway shipped: 408, 409 and 425 join it. Each is transient by construction — the server gave up
|
|
428
|
-
* waiting for a body it never fully read, a concurrent writer won the round, the handshake was not
|
|
429
|
-
* finished — so not retrying them was a gap rather than a policy, and one narrower table in one
|
|
430
|
-
* package was how it stayed invisible. The `code` branch has no equivalent in core and stays here:
|
|
431
|
-
* that table is HTTP status only, and a socket that timed out never produced one.
|
|
432
|
-
*/
|
|
433
|
-
/**
|
|
434
|
-
* A transport that never produced a response. Node spells these `ETIMEDOUT`/`ECONNRESET`; Bun's
|
|
435
|
-
* `fetch` spells a refused, dropped or unopenable connection `ConnectionRefused`,
|
|
436
|
-
* `ConnectionClosed` and `FailedToOpenSocket` — which this list did not know, so on the one runtime
|
|
437
|
-
* the framework runs on a provider mid-restart was never retried.
|
|
438
|
-
*/
|
|
439
|
-
const RETRYABLE_TRANSPORT_CODES: ReadonlySet<string> = new Set([
|
|
440
|
-
'ETIMEDOUT',
|
|
441
|
-
'ECONNRESET',
|
|
442
|
-
'ConnectionRefused',
|
|
443
|
-
'ConnectionClosed',
|
|
444
|
-
'FailedToOpenSocket',
|
|
445
|
-
]);
|
|
446
|
-
|
|
447
|
-
export function isRetryable(error: unknown): boolean {
|
|
448
|
-
if (typeof error !== 'object' || error === null) return false;
|
|
449
|
-
// A `Provider` is the APP's object, so the value it rejected with is one the framework did not
|
|
450
|
-
// build: `e.status` is a getter call and, on a `Proxy`, a trap. A value that fights being read
|
|
451
|
-
// cannot be SHOWN to be retryable, and this runs inside the catch block that has nothing left
|
|
452
|
-
// to answer with — so it fails closed rather than raising.
|
|
453
|
-
try {
|
|
454
|
-
const e = error as { status?: unknown; code?: unknown };
|
|
455
|
-
if (typeof e.status === 'number') return isRetryableStatus(e.status);
|
|
456
|
-
return typeof e.code === 'string' && RETRYABLE_TRANSPORT_CODES.has(e.code);
|
|
457
|
-
} catch {
|
|
458
|
-
return false;
|
|
459
|
-
}
|
|
460
|
-
}
|
|
461
|
-
|
|
462
372
|
/**
|
|
463
373
|
* Cache key. Every field that changes the answer is in it — a key that ignores `effort` or
|
|
464
374
|
* `system` would serve one prompt's answer for another — and each tool WHOLE: every `llm()` tool
|
package/src/hive-errors.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// The X_HIVE_* codes, apart from ./errors only because one file has one job and that catalogue is
|
|
2
2
|
// already at its ceiling — the same split `eval-errors.ts` made. The codes, their titles and the
|
|
3
|
-
// single `registerErrorCodes` call stay in ./
|
|
3
|
+
// single `registerErrorCodes` call stay in ./error-codes: one owner, one registration, one place a
|
|
4
4
|
// duplicate can surface.
|
|
5
5
|
|
|
6
6
|
import { UltimateError } from '@ultimat3/core';
|
package/src/in-process-tool.ts
CHANGED
|
@@ -60,18 +60,27 @@ export function inProcessTool<TInput extends StandardSchemaV1>(
|
|
|
60
60
|
inputJsonSchema: toWireSchema(def.input),
|
|
61
61
|
inProcess: true,
|
|
62
62
|
...(def.discloseErrors === undefined ? {} : { discloseErrors: def.discloseErrors }),
|
|
63
|
+
parseInput: (raw) => parseToolInput(def, raw),
|
|
63
64
|
async run({ input, actor }) {
|
|
64
|
-
|
|
65
|
-
if (parsed.issues !== undefined) {
|
|
66
|
-
// The line only: the structured issues are `@ultimat3/action`'s own shape, for a form.
|
|
67
|
-
throw new InputInvalidError(
|
|
68
|
-
def.name,
|
|
69
|
-
formatIssues(parsed.issues).join('; '),
|
|
70
|
-
undefined,
|
|
71
|
-
'tool',
|
|
72
|
-
);
|
|
73
|
-
}
|
|
74
|
-
return def.execute({ input: parsed.value, actor });
|
|
65
|
+
return def.execute({ input: await parseToolInput(def, input), actor });
|
|
75
66
|
},
|
|
76
67
|
};
|
|
77
68
|
}
|
|
69
|
+
|
|
70
|
+
/** The input parse `run` makes, alone — also what an agent eval holds this tool's double to. */
|
|
71
|
+
async function parseToolInput<TInput extends StandardSchemaV1>(
|
|
72
|
+
def: InProcessToolDef<TInput>,
|
|
73
|
+
raw: unknown,
|
|
74
|
+
): Promise<InferOutput<TInput>> {
|
|
75
|
+
const parsed = await validateAsync(def.input, raw);
|
|
76
|
+
if (parsed.issues !== undefined) {
|
|
77
|
+
// The line only: the structured issues are `@ultimat3/action`'s own shape, for a form.
|
|
78
|
+
throw new InputInvalidError(
|
|
79
|
+
def.name,
|
|
80
|
+
formatIssues(parsed.issues).join('; '),
|
|
81
|
+
undefined,
|
|
82
|
+
'tool',
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
return parsed.value;
|
|
86
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -8,6 +8,7 @@ export { agent } from './agent';
|
|
|
8
8
|
export type { AgentCompaction, AgentCompactOption, AgentCompactRule } from './agent-compact-def';
|
|
9
9
|
export type {
|
|
10
10
|
AgentAction,
|
|
11
|
+
AgentCheckpoint,
|
|
11
12
|
AgentContinuation,
|
|
12
13
|
AgentRun,
|
|
13
14
|
AgentTranscript,
|
|
@@ -81,6 +82,7 @@ export {
|
|
|
81
82
|
} from './content-blocks';
|
|
82
83
|
export type { ContentRefusal } from './content-errors';
|
|
83
84
|
export { AiContentUnsupportedError } from './content-errors';
|
|
85
|
+
export { AiContextOverflowError } from './context-overflow';
|
|
84
86
|
export { COST_SCALE_DIGITS, costScale, exactCostOf, roundUpCost } from './cost';
|
|
85
87
|
export type { EchoProvider, EchoProviderInput } from './echo-provider';
|
|
86
88
|
export { echoProvider } from './echo-provider';
|
|
@@ -93,12 +95,11 @@ export {
|
|
|
93
95
|
normalizeVector,
|
|
94
96
|
wordTokens,
|
|
95
97
|
} from './embeddings';
|
|
96
|
-
export type { AiErrorCode } from './
|
|
98
|
+
export type { AiErrorCode } from './error-codes';
|
|
99
|
+
export { AI_ERROR_CODES, AI_ERROR_TITLES } from './error-codes';
|
|
97
100
|
export {
|
|
98
101
|
AgentMaxTurnsError,
|
|
99
102
|
AgentToolUnexposedError,
|
|
100
|
-
AI_ERROR_CODES,
|
|
101
|
-
AI_ERROR_TITLES,
|
|
102
103
|
AiBudgetExceededError,
|
|
103
104
|
AiGatewayMissingError,
|
|
104
105
|
AiKeyMissingError,
|
|
@@ -133,14 +134,19 @@ export {
|
|
|
133
134
|
EvalMissingError,
|
|
134
135
|
EvalRecordingError,
|
|
135
136
|
EvalThresholdError,
|
|
137
|
+
EvalToolUndoubledError,
|
|
136
138
|
} from './eval-errors';
|
|
137
139
|
export type {
|
|
140
|
+
AgentEvalCase,
|
|
141
|
+
AgentEvalInput,
|
|
138
142
|
CaseResult,
|
|
139
143
|
DefineEvalInput,
|
|
140
144
|
Eval,
|
|
141
145
|
EvalCase,
|
|
142
146
|
EvalFact,
|
|
143
147
|
EvalResult,
|
|
148
|
+
EvaluatedAgent,
|
|
149
|
+
PromptEvalInput,
|
|
144
150
|
} from './evals';
|
|
145
151
|
export {
|
|
146
152
|
baselineFrom,
|
|
@@ -150,9 +156,16 @@ export {
|
|
|
150
156
|
resetEvals,
|
|
151
157
|
} from './evals';
|
|
152
158
|
export type { AiFetch } from './fetch-seam';
|
|
153
|
-
export type {
|
|
159
|
+
export type {
|
|
160
|
+
Gateway,
|
|
161
|
+
GatewayBreakerOption,
|
|
162
|
+
GatewayCache,
|
|
163
|
+
ProviderGatewayInput,
|
|
164
|
+
RetryPolicy,
|
|
165
|
+
} from './gateway';
|
|
154
166
|
export {
|
|
155
167
|
backoffMs,
|
|
168
|
+
DEFAULT_GATEWAY_BREAKER,
|
|
156
169
|
DEFAULT_GATEWAY_RETRY,
|
|
157
170
|
isRetryable,
|
|
158
171
|
promptCacheKey,
|
|
@@ -165,7 +178,7 @@ export type { HiveMember, HiveMemberError, HiveOutput, HiveResult } from './hive
|
|
|
165
178
|
export type { InProcessToolDef } from './in-process-tool';
|
|
166
179
|
export { inProcessTool } from './in-process-tool';
|
|
167
180
|
export type { LlmAction, LlmBudget, LlmCall, LlmDef, LlmVarsArgs } from './llm';
|
|
168
|
-
export { llm } from './llm';
|
|
181
|
+
export { LlmTextChunk, llm } from './llm';
|
|
169
182
|
export type { LlmCache, LlmScopeArgs, LlmSemanticCache } from './llm-cache';
|
|
170
183
|
export type { LlmStreamChunk } from './llm-stream';
|
|
171
184
|
export type { ModelSource } from './model-resolve';
|
|
@@ -220,6 +233,8 @@ export type {
|
|
|
220
233
|
PromptCacheHint,
|
|
221
234
|
PromptCacheOption,
|
|
222
235
|
PromptCacheRule,
|
|
236
|
+
ProviderCacheRow,
|
|
237
|
+
ProviderPromptCache,
|
|
223
238
|
SystemParts,
|
|
224
239
|
SystemPrompt,
|
|
225
240
|
} from './prompt-cache';
|
|
@@ -275,7 +290,8 @@ export {
|
|
|
275
290
|
export { assertNoSecrets } from './redaction';
|
|
276
291
|
export type { RemoteEmbedder, RemoteEmbedderInput } from './remote-embedder';
|
|
277
292
|
export { remoteEmbedder } from './remote-embedder';
|
|
278
|
-
export
|
|
293
|
+
export { meteredRun } from './run-meter';
|
|
294
|
+
export type { AiRuntimeInput, Redactor, ToolDouble } from './runtime';
|
|
279
295
|
export {
|
|
280
296
|
aiEmbedder,
|
|
281
297
|
aiGateway,
|
|
@@ -284,8 +300,9 @@ export {
|
|
|
284
300
|
semanticCacheFor,
|
|
285
301
|
} from './runtime';
|
|
286
302
|
export type { Sampling } from './sampling';
|
|
287
|
-
export type { Scorer } from './scorers';
|
|
303
|
+
export type { EvalRun, EvalToolCall, Scorer, ScorerInput } from './scorers';
|
|
288
304
|
export {
|
|
305
|
+
calledTool,
|
|
289
306
|
contains,
|
|
290
307
|
exact,
|
|
291
308
|
jsonSchemaValid,
|
|
@@ -293,6 +310,13 @@ export {
|
|
|
293
310
|
llmJudge,
|
|
294
311
|
numericTolerance,
|
|
295
312
|
} from './scorers';
|
|
313
|
+
export type {
|
|
314
|
+
ScriptedCall,
|
|
315
|
+
ScriptedProvider,
|
|
316
|
+
ScriptedProviderInput,
|
|
317
|
+
ScriptedTurn,
|
|
318
|
+
} from './scripted-provider';
|
|
319
|
+
export { AiScriptExhaustedError, scriptedProvider } from './scripted-provider';
|
|
296
320
|
export type {
|
|
297
321
|
AgentTool,
|
|
298
322
|
JsonSchema,
|
package/src/llm.ts
CHANGED
|
@@ -21,17 +21,22 @@
|
|
|
21
21
|
* same rules.
|
|
22
22
|
*/
|
|
23
23
|
|
|
24
|
-
import type {
|
|
25
|
-
import { action } from '@ultimat3/action';
|
|
24
|
+
import type { InvokeOptions, StreamAction } from '@ultimat3/action';
|
|
25
|
+
import { action, chunksRead } from '@ultimat3/action';
|
|
26
26
|
import type { Ctx, Span, SpanAttributes } from '@ultimat3/core';
|
|
27
27
|
import { finiteCount, withSpan } from '@ultimat3/core';
|
|
28
28
|
import type { InferInput, InferOutput, StandardSchemaV1 } from '@ultimat3/schema';
|
|
29
|
-
import { formatIssues, validateAsync } from '@ultimat3/schema';
|
|
29
|
+
import { formatIssues, t, validateAsync } from '@ultimat3/schema';
|
|
30
30
|
import { runTotal } from './agent-usage';
|
|
31
31
|
import { LlmTruncatedError } from './answer-errors';
|
|
32
32
|
import type { BudgetLimits } from './budget';
|
|
33
33
|
import { budgetKeysFor, currentBudget, screenedMoney, withBudget } from './budget';
|
|
34
|
-
import {
|
|
34
|
+
import {
|
|
35
|
+
AiTransportError,
|
|
36
|
+
LlmOutputInvalidError,
|
|
37
|
+
LlmRefusedError,
|
|
38
|
+
LlmStreamInvalidError,
|
|
39
|
+
} from './errors';
|
|
35
40
|
import type { Gateway } from './gateway';
|
|
36
41
|
import { openCache } from './llm-cache';
|
|
37
42
|
import type { LlmBudget, LlmDef } from './llm-def';
|
|
@@ -63,13 +68,21 @@ const DEFAULT_MAX_TOKENS = 4_096;
|
|
|
63
68
|
|
|
64
69
|
export type { LlmBudget, LlmCall, LlmDef, LlmVarsArgs } from './llm-def';
|
|
65
70
|
|
|
71
|
+
/**
|
|
72
|
+
* What `llm()`'s route streams to a caller asking for `text/event-stream`: the answer's text, as it
|
|
73
|
+
* arrives. `thinking` never crosses the wire — reasoning is for the server's own `.stream()`
|
|
74
|
+
* consumer to decide about, not for a browser to render by default. The final frame is `done`,
|
|
75
|
+
* the value that satisfied `output`.
|
|
76
|
+
*/
|
|
77
|
+
export const LlmTextChunk = t.object({ type: t.literal('text'), text: t.string });
|
|
78
|
+
|
|
66
79
|
/**
|
|
67
80
|
* An `action` with one extra way to be called. Every projection an action has, it has — its MCP tool,
|
|
68
81
|
* `.openapi()`, `.client()`, `.job()`, `.contract()` — plus a transport for the case an action's
|
|
69
82
|
* single return value cannot serve: text on a screen before the answer is finished.
|
|
70
83
|
*/
|
|
71
84
|
export interface LlmAction<TInput extends StandardSchemaV1, TOutput extends StandardSchemaV1>
|
|
72
|
-
extends
|
|
85
|
+
extends StreamAction<TInput, TOutput, typeof LlmTextChunk> {
|
|
73
86
|
/**
|
|
74
87
|
* The same call, delivered as it arrives. Runs the action's policy, input parse, budget scope,
|
|
75
88
|
* semantic cache and span exactly as calling it would — the invocation IS an ordinary one,
|
|
@@ -106,12 +119,30 @@ export function llm<
|
|
|
106
119
|
// a call that cannot answer.
|
|
107
120
|
finiteCount('llm', 'maxTokens', def.maxTokens ?? DEFAULT_MAX_TOKENS, 1);
|
|
108
121
|
const sampling = screenSampling('llm', def);
|
|
109
|
-
const built = action<TInput, TOutput>({
|
|
122
|
+
const built = action<TInput, TOutput, typeof LlmTextChunk>({
|
|
110
123
|
input: def.input,
|
|
111
124
|
output: def.output,
|
|
112
125
|
policy: def.policy,
|
|
113
126
|
...(def.mcp === undefined ? {} : { mcp: def.mcp }),
|
|
114
|
-
|
|
127
|
+
// Declared, so the route answers `text/event-stream` when asked. Drained (MCP, `.job()`, JSON,
|
|
128
|
+
// a direct call) it is the non-streaming call, repair turn included; only a caller READING the
|
|
129
|
+
// chunks gets the one-attempt streamed turn, exactly as server-side `.stream()` does.
|
|
130
|
+
stream: LlmTextChunk,
|
|
131
|
+
async *handle(args) {
|
|
132
|
+
if (!chunksRead()) return await generate(def, respond, sampling, args);
|
|
133
|
+
const chunks = llmStream<InferOutput<TOutput>>((sink) =>
|
|
134
|
+
withLlmSink(sink, () => generate(def, respond, sampling, args)),
|
|
135
|
+
);
|
|
136
|
+
for await (const chunk of chunks) {
|
|
137
|
+
if (chunk.type === 'text') yield { type: 'text' as const, text: chunk.text };
|
|
138
|
+
else if (chunk.type === 'done') return chunk.value;
|
|
139
|
+
}
|
|
140
|
+
// `llmStream` ends with `done` or throws; reaching here is a sink that closed early.
|
|
141
|
+
throw new AiTransportError({
|
|
142
|
+
provider: 'gateway',
|
|
143
|
+
detail: 'the streamed answer ended without its value',
|
|
144
|
+
});
|
|
145
|
+
},
|
|
115
146
|
});
|
|
116
147
|
return streamable(built);
|
|
117
148
|
}
|
|
@@ -126,7 +157,7 @@ export function llm<
|
|
|
126
157
|
* The original is captured first, or the override would call itself.
|
|
127
158
|
*/
|
|
128
159
|
function streamable<TInput extends StandardSchemaV1, TOutput extends StandardSchemaV1>(
|
|
129
|
-
target:
|
|
160
|
+
target: StreamAction<TInput, TOutput, typeof LlmTextChunk>,
|
|
130
161
|
): LlmAction<TInput, TOutput> {
|
|
131
162
|
const rename = target.named.bind(target);
|
|
132
163
|
const self: LlmAction<TInput, TOutput> = Object.assign(target, {
|
package/src/model-resolve.ts
CHANGED
|
@@ -7,7 +7,13 @@ import type { ModelId } from './models';
|
|
|
7
7
|
import { aiGateway } from './runtime';
|
|
8
8
|
|
|
9
9
|
/** Where a model was resolved. Bounded, and the first word of the refusal's cause. */
|
|
10
|
-
export type ModelSite =
|
|
10
|
+
export type ModelSite =
|
|
11
|
+
| 'llm'
|
|
12
|
+
| 'agent'
|
|
13
|
+
| 'gateway'
|
|
14
|
+
| 'provider'
|
|
15
|
+
| 'echo-provider'
|
|
16
|
+
| 'scripted-provider';
|
|
11
17
|
|
|
12
18
|
/** The three places, spelled once so the refusal and the docs cannot drift apart. */
|
|
13
19
|
const PLACES =
|
package/src/openai-body.ts
CHANGED
|
@@ -9,10 +9,13 @@ import { AiRequestInvalidError } from './errors';
|
|
|
9
9
|
import type { Effort, ModelId, ThinkingMode } from './models';
|
|
10
10
|
import { modelSpec } from './models';
|
|
11
11
|
import { toOpenAiMessages, toOpenAiTools, toolChoiceFor } from './openai-messages';
|
|
12
|
+
import type { PromptCacheRule, ProviderCacheRow } from './prompt-cache';
|
|
12
13
|
import { OPENAI_CACHE_KEY_MAX, promptCacheRule, SESSION_KEY_MAX } from './prompt-cache';
|
|
13
14
|
import type { GenerateRequest } from './provider';
|
|
14
15
|
import { samplingBody } from './sampling';
|
|
15
16
|
|
|
17
|
+
type CacheRule = Pick<PromptCacheRule, 'marks' | 'session'>;
|
|
18
|
+
|
|
16
19
|
export interface ChatCompletionBodyInput {
|
|
17
20
|
readonly request: GenerateRequest;
|
|
18
21
|
/** Already resolved — the gateway picks the model, the provider never guesses mid-request. */
|
|
@@ -22,6 +25,8 @@ export interface ChatCompletionBodyInput {
|
|
|
22
25
|
readonly stream: boolean;
|
|
23
26
|
/** The endpoint, for `PROMPT_CACHE_RULES` — which host this is decides what a cache hint sends. */
|
|
24
27
|
readonly baseUrl?: string | undefined;
|
|
28
|
+
/** The provider's own screened rows (`promptCache.rules`), read before the table. */
|
|
29
|
+
readonly cacheRows?: readonly ProviderCacheRow[];
|
|
25
30
|
}
|
|
26
31
|
|
|
27
32
|
/**
|
|
@@ -38,7 +43,7 @@ export function chatCompletionBody(input: ChatCompletionBodyInput): Record<strin
|
|
|
38
43
|
const spec = modelSpec(model);
|
|
39
44
|
assertMediaContent(request.messages, { provider, model });
|
|
40
45
|
const hint = request.promptCache;
|
|
41
|
-
const rule = promptCacheRule('openai', input.baseUrl, model);
|
|
46
|
+
const rule = promptCacheRule('openai', input.baseUrl, model, input.cacheRows);
|
|
42
47
|
const marked = hint !== undefined && rule.marks === 'parts';
|
|
43
48
|
const body: Record<string, unknown> = {
|
|
44
49
|
model,
|
|
@@ -71,15 +76,17 @@ export function chatCompletionBody(input: ChatCompletionBodyInput): Record<strin
|
|
|
71
76
|
return body;
|
|
72
77
|
}
|
|
73
78
|
|
|
74
|
-
/**
|
|
79
|
+
/**
|
|
80
|
+
* The headers a cache hint adds under this request's resolved rule: OpenRouter's stickiness key,
|
|
81
|
+
* or none.
|
|
82
|
+
*/
|
|
75
83
|
export function sessionHeaders(
|
|
76
84
|
request: GenerateRequest,
|
|
77
|
-
|
|
78
|
-
baseUrl: string | undefined,
|
|
85
|
+
rule: CacheRule,
|
|
79
86
|
): Readonly<Record<string, string>> {
|
|
80
87
|
const session = request.promptCache?.session;
|
|
81
88
|
if (session === undefined) return {};
|
|
82
|
-
if (
|
|
89
|
+
if (rule.session !== 'x-session-id') return {};
|
|
83
90
|
return { 'x-session-id': sessionKey(session) };
|
|
84
91
|
}
|
|
85
92
|
|