@ultimat3/ai 27.8.1 → 27.10.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 +137 -137
- package/README.md +95 -4
- package/package.json +11 -11
- package/src/agent-continue.ts +434 -0
- package/src/agent-declared.ts +157 -0
- package/src/agent-def.ts +134 -0
- package/src/agent-errors.ts +69 -0
- package/src/agent-guards.ts +114 -0
- package/src/agent-system.ts +37 -0
- package/src/agent-turn.ts +54 -0
- package/src/agent-usage.ts +63 -0
- package/src/agent.ts +190 -250
- package/src/answer-errors.ts +88 -0
- package/src/billed.ts +87 -0
- package/src/budget.ts +40 -12
- package/src/cost.ts +146 -0
- package/src/echo-provider.ts +3 -1
- package/src/errors-retry.ts +14 -0
- package/src/errors.ts +26 -19
- package/src/failure-disclosure.ts +37 -1
- package/src/gateway.ts +62 -9
- package/src/in-process-tool.ts +77 -0
- package/src/index.ts +47 -3
- package/src/llm-def.ts +97 -0
- package/src/llm.ts +89 -66
- package/src/models.ts +45 -1
- package/src/openai-body.ts +56 -4
- package/src/openai-messages.ts +57 -5
- package/src/openai-provider.ts +26 -5
- package/src/openai-wire.ts +184 -69
- package/src/prompt-cache-blocks.ts +114 -0
- package/src/prompt-cache.ts +189 -0
- package/src/provider.ts +71 -45
- package/src/run-meter.ts +147 -0
- package/src/sampling.ts +86 -0
- package/src/tool-arguments.ts +88 -0
- package/src/tools.ts +51 -6
- package/src/wire.ts +15 -14
package/src/billed.ts
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
// Single responsibility: what a model call, or a whole run, was BILLED before it failed — the
|
|
2
|
+
// shape it travels in on a thrown error's `meta.billed`, the reader a job uses to store it, and
|
|
3
|
+
// the arithmetic that adds one call to a run.
|
|
4
|
+
//
|
|
5
|
+
// It exists because a failure after the provider answered is not free: a truncated answer, tool
|
|
6
|
+
// arguments that would not parse, a schema no repair turn satisfied — each was generated and
|
|
7
|
+
// charged for. A job that only sees the error would otherwise have to guess what the attempt cost.
|
|
8
|
+
|
|
9
|
+
import { isUltimateError } from '@ultimat3/core';
|
|
10
|
+
import type { Money } from '@ultimat3/money';
|
|
11
|
+
import type { TokenUsage } from './provider';
|
|
12
|
+
|
|
13
|
+
/** The `meta` key every failure after a provider answered carries its bill under. */
|
|
14
|
+
export const BILLED_META = 'billed';
|
|
15
|
+
|
|
16
|
+
export interface Billed {
|
|
17
|
+
/** Summed over every provider call that ANSWERED — the provider's own counts. */
|
|
18
|
+
readonly usage: TokenUsage;
|
|
19
|
+
/** The price of `usage`, rounded up once. */
|
|
20
|
+
readonly cost: Money;
|
|
21
|
+
/** Provider calls that answered and are counted above. */
|
|
22
|
+
readonly calls: number;
|
|
23
|
+
/**
|
|
24
|
+
* Provider calls that were started and never reported usage: aborted by the caller or a
|
|
25
|
+
* deadline, cut mid-stream, or failed in transport. What they cost CANNOT be known from here —
|
|
26
|
+
* a provider may bill the tokens it generated before the socket closed, and usage is the last
|
|
27
|
+
* thing a response carries. Zero means the figures above are the whole bill.
|
|
28
|
+
*/
|
|
29
|
+
readonly unsettled: number;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const COUNTERS = ['inputTokens', 'outputTokens', 'cacheReadTokens', 'cacheWriteTokens'] as const;
|
|
33
|
+
|
|
34
|
+
const isCount = (value: unknown): value is number =>
|
|
35
|
+
typeof value === 'number' && Number.isFinite(value) && value >= 0;
|
|
36
|
+
|
|
37
|
+
const isScale = (value: unknown): value is number =>
|
|
38
|
+
typeof value === 'number' && Number.isInteger(value) && value >= 0 && value <= 15;
|
|
39
|
+
|
|
40
|
+
function asRecord(value: unknown): Record<string, unknown> | undefined {
|
|
41
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
|
42
|
+
? (value as Record<string, unknown>)
|
|
43
|
+
: undefined;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* What `error` says was billed, or `undefined` when it carries no bill — it was thrown before any
|
|
48
|
+
* provider answered, or it is not one of this package's failures. Total: the value came out of a
|
|
49
|
+
* `catch`, and `meta` is data an app stores and reads back, so every field is checked.
|
|
50
|
+
*/
|
|
51
|
+
export function billedOf(error: unknown): Billed | undefined {
|
|
52
|
+
if (!isUltimateError(error)) return undefined;
|
|
53
|
+
let raw: unknown;
|
|
54
|
+
try {
|
|
55
|
+
raw = error.meta?.[BILLED_META];
|
|
56
|
+
} catch {
|
|
57
|
+
return undefined;
|
|
58
|
+
}
|
|
59
|
+
const record = asRecord(raw);
|
|
60
|
+
const usage = asRecord(record?.['usage']);
|
|
61
|
+
const cost = asRecord(record?.['cost']);
|
|
62
|
+
if (record === undefined || usage === undefined || cost === undefined) return undefined;
|
|
63
|
+
if (!COUNTERS.every((key) => isCount(usage[key]))) return undefined;
|
|
64
|
+
if (!isCount(cost['minor']) || typeof cost['currency'] !== 'string') return undefined;
|
|
65
|
+
if (!isCount(record['calls']) || !isCount(record['unsettled'])) return undefined;
|
|
66
|
+
return {
|
|
67
|
+
usage: {
|
|
68
|
+
inputTokens: usage['inputTokens'] as number,
|
|
69
|
+
outputTokens: usage['outputTokens'] as number,
|
|
70
|
+
cacheReadTokens: usage['cacheReadTokens'] as number,
|
|
71
|
+
cacheWriteTokens: usage['cacheWriteTokens'] as number,
|
|
72
|
+
},
|
|
73
|
+
// The scale travels with the amount: an exact cost read without it is a million times over.
|
|
74
|
+
cost: {
|
|
75
|
+
minor: cost['minor'],
|
|
76
|
+
currency: cost['currency'],
|
|
77
|
+
...(isScale(cost['scale']) ? { scale: cost['scale'] } : {}),
|
|
78
|
+
},
|
|
79
|
+
calls: record['calls'],
|
|
80
|
+
unsettled: record['unsettled'],
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** One answered call, as a bill. */
|
|
85
|
+
export function billedCall(usage: TokenUsage, cost: Money): Billed {
|
|
86
|
+
return { usage, cost, calls: 1, unsettled: 0 };
|
|
87
|
+
}
|
package/src/budget.ts
CHANGED
|
@@ -13,7 +13,8 @@
|
|
|
13
13
|
import type { Actor } from '@ultimat3/core';
|
|
14
14
|
import { assert, asyncContext, finiteCount } from '@ultimat3/core';
|
|
15
15
|
import type { Money } from '@ultimat3/money';
|
|
16
|
-
import { assertSameCurrency } from '@ultimat3/money';
|
|
16
|
+
import { assertSameCurrency, commonScale, exponentOf, minorAt } from '@ultimat3/money';
|
|
17
|
+
import { compareMoney, costScale, ledgerUnits, roundUpCost } from './cost';
|
|
17
18
|
import { AiBudgetExceededError } from './errors';
|
|
18
19
|
import type { GenerateRequest, TokenUsage } from './provider';
|
|
19
20
|
import { estimateCost, estimateInputTokens, estimateTokens, totalTokens } from './provider';
|
|
@@ -57,11 +58,12 @@ export interface SpendEstimate {
|
|
|
57
58
|
}
|
|
58
59
|
|
|
59
60
|
/** Price a request pre-flight. The pessimistic read on purpose — see `estimateCost`. */
|
|
60
|
-
export function estimateSpend(request: GenerateRequest): SpendEstimate {
|
|
61
|
+
export function estimateSpend(request: GenerateRequest, marked = false): SpendEstimate {
|
|
61
62
|
return {
|
|
62
63
|
inputTokens: estimateInputTokens(request),
|
|
63
64
|
tokens: estimateTokens(request),
|
|
64
|
-
|
|
65
|
+
// `marked`: a provider would send this request with a cache mark, so it can bill as a write.
|
|
66
|
+
cost: estimateCost(request, marked),
|
|
65
67
|
};
|
|
66
68
|
}
|
|
67
69
|
|
|
@@ -186,7 +188,10 @@ export interface BudgetLedgerInput {
|
|
|
186
188
|
|
|
187
189
|
export interface BudgetReport {
|
|
188
190
|
readonly requestTokens: number;
|
|
191
|
+
/** Everything recorded, rounded up ONCE to the currency's minor unit. */
|
|
189
192
|
readonly cost: Money;
|
|
193
|
+
/** The same sum exactly, at `costScale(currency)` — micro-units of the minor unit. */
|
|
194
|
+
readonly costExact: Money;
|
|
190
195
|
readonly limits: BudgetLimits;
|
|
191
196
|
readonly actorSpent: number;
|
|
192
197
|
readonly orgSpent: number;
|
|
@@ -198,7 +203,8 @@ export class BudgetLedger {
|
|
|
198
203
|
private readonly orgKey: string | undefined;
|
|
199
204
|
private readonly store: BudgetStore;
|
|
200
205
|
private requestTokens = 0;
|
|
201
|
-
|
|
206
|
+
/** Summed EXACTLY, in micro-units of the minor unit, and rounded up once — when it is read. */
|
|
207
|
+
private costMicro = 0n;
|
|
202
208
|
private readonly currency: string;
|
|
203
209
|
/**
|
|
204
210
|
* The ledger this one was `derive`d from, or `undefined` for a scope's root. Set by `derive`
|
|
@@ -334,8 +340,11 @@ export class BudgetLedger {
|
|
|
334
340
|
async record(usage: TokenUsage, cost: Money, reservation?: BudgetReservation): Promise<void> {
|
|
335
341
|
// Up the chain, because `derive` copies the currency: a scope's reported cost is its own
|
|
336
342
|
// calls plus every call made under a ledger derived from it.
|
|
343
|
+
// `cost` may be exact (`GenerateResult.costExact`, a finer `scale`) or already rounded; either
|
|
344
|
+
// way it is widened to one scale and summed there, so fourteen cheap calls are rounded once.
|
|
345
|
+
const micro = ledgerUnits(cost);
|
|
337
346
|
for (let l: BudgetLedger | undefined = this; l !== undefined; l = l.parent) {
|
|
338
|
-
l.
|
|
347
|
+
l.costMicro += micro;
|
|
339
348
|
}
|
|
340
349
|
await this.debit(totalTokens(usage) - (reservation?.tokens ?? 0));
|
|
341
350
|
}
|
|
@@ -373,13 +382,23 @@ export class BudgetLedger {
|
|
|
373
382
|
async report(): Promise<BudgetReport> {
|
|
374
383
|
return {
|
|
375
384
|
requestTokens: this.requestTokens,
|
|
376
|
-
cost:
|
|
385
|
+
cost: roundUpCost(this.exactCost()),
|
|
386
|
+
costExact: this.exactCost(),
|
|
377
387
|
limits: this.limits,
|
|
378
388
|
actorSpent: this.actorKey === undefined ? 0 : await this.store.spent(this.actorKey),
|
|
379
389
|
orgSpent: this.orgKey === undefined ? 0 : await this.store.spent(this.orgKey),
|
|
380
390
|
};
|
|
381
391
|
}
|
|
382
392
|
|
|
393
|
+
private exactCost(): Money {
|
|
394
|
+
const minor = Number(this.costMicro);
|
|
395
|
+
return {
|
|
396
|
+
minor: Number.isSafeInteger(minor) ? minor : Number.MAX_SAFE_INTEGER,
|
|
397
|
+
currency: this.currency,
|
|
398
|
+
scale: costScale(this.currency),
|
|
399
|
+
};
|
|
400
|
+
}
|
|
401
|
+
|
|
383
402
|
private assertScope(scope: string, limit: number | undefined, spent: number, want: number): void {
|
|
384
403
|
if (limit === undefined) return;
|
|
385
404
|
const remaining = limit - spent;
|
|
@@ -393,13 +412,21 @@ export class BudgetLedger {
|
|
|
393
412
|
const limit = this.limits.costPerCall;
|
|
394
413
|
if (limit === undefined) return;
|
|
395
414
|
assertSameCurrency(limit, cost);
|
|
396
|
-
if (cost
|
|
415
|
+
if (compareMoney(cost, limit) > 0) {
|
|
416
|
+
// ONE scale for all three figures: the estimate is in the currency's minor units and the
|
|
417
|
+
// ceiling may be finer, and printing each in its own under one label compares nothing.
|
|
418
|
+
const scale = commonScale(cost, limit);
|
|
419
|
+
const ceiling = Number(minorAt(limit, scale));
|
|
397
420
|
throw new AiBudgetExceededError({
|
|
398
421
|
scope: 'costPerCall',
|
|
399
|
-
requested: cost
|
|
400
|
-
remaining:
|
|
401
|
-
limit:
|
|
402
|
-
|
|
422
|
+
requested: Number(minorAt(cost, scale)),
|
|
423
|
+
remaining: ceiling,
|
|
424
|
+
limit: ceiling,
|
|
425
|
+
// The currency's own minor units say so as they always did; a finer ceiling names its scale.
|
|
426
|
+
unit:
|
|
427
|
+
scale === exponentOf(limit.currency)
|
|
428
|
+
? `${limit.currency} minor units`
|
|
429
|
+
: `${limit.currency} at scale ${scale}`,
|
|
403
430
|
});
|
|
404
431
|
}
|
|
405
432
|
}
|
|
@@ -455,7 +482,8 @@ function tighterMoney(a: Money | undefined, b: Money | undefined): Money | undef
|
|
|
455
482
|
if (a === undefined) return b;
|
|
456
483
|
if (b === undefined) return a;
|
|
457
484
|
assertSameCurrency(a, b);
|
|
458
|
-
|
|
485
|
+
// As money: a ceiling at a finer `scale` is not larger for having a larger `minor`.
|
|
486
|
+
return compareMoney(a, b) <= 0 ? a : b;
|
|
459
487
|
}
|
|
460
488
|
|
|
461
489
|
const storage = asyncContext<BudgetLedger>('an AI budget');
|
package/src/cost.ts
ADDED
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
// Single responsibility: the money arithmetic over a call's reported usage — what it cost exactly,
|
|
2
|
+
// and that figure rounded up to the currency's own minor unit, once.
|
|
3
|
+
//
|
|
4
|
+
// Exact, in integers: a price is `Money` per million tokens at whatever `scale` the row states
|
|
5
|
+
// (`{ minor: 75, currency: 'USD', scale: 3 }` is $0.075), and a cost is `Money` at
|
|
6
|
+
// `COST_SCALE_DIGITS` more digits than the currency's minor unit — millionths of a cent for USD.
|
|
7
|
+
// The multiplication is done in bigint, so no call, however large, loses a digit on the way.
|
|
8
|
+
|
|
9
|
+
import type { Money } from '@ultimat3/money';
|
|
10
|
+
import { commonScale, exponentOf, minorAt, moneyScale } from '@ultimat3/money';
|
|
11
|
+
import type { ModelId, ModelSpec } from './models';
|
|
12
|
+
import { modelSpec, PRICE_SCALE_DIGITS } from './models';
|
|
13
|
+
import type { TokenUsage } from './provider';
|
|
14
|
+
|
|
15
|
+
/** Digits an exact cost carries beyond the currency's minor unit: micro-units of it. */
|
|
16
|
+
export const COST_SCALE_DIGITS = PRICE_SCALE_DIGITS;
|
|
17
|
+
|
|
18
|
+
const MICRO = 10n ** BigInt(COST_SCALE_DIGITS);
|
|
19
|
+
const MILLION = 1_000_000n;
|
|
20
|
+
/** Prices are summed in TWENTIETHS so the default cache multipliers (0.1x, 1.25x) stay whole. */
|
|
21
|
+
const TWENTIETHS = 20n;
|
|
22
|
+
|
|
23
|
+
/** The scale an exact cost in `currency` is stated at. */
|
|
24
|
+
export const costScale = (currency: string): number => exponentOf(currency) + COST_SCALE_DIGITS;
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* What one call cost, EXACTLY to a micro-unit of the currency's minor unit (rounded up at that
|
|
28
|
+
* digit, which no real price reaches). `Money` with an explicit `scale` — never a float, never a
|
|
29
|
+
* second type: `{ minor: 105_000, currency: 'USD', scale: 8 }` is 0.105 cents.
|
|
30
|
+
*
|
|
31
|
+
* The one price lookup: a model the app never `registerModel`-ed is `X_AI_MODEL_UNKNOWN` here, so
|
|
32
|
+
* no call is priced — and no budget reserved — against a number nobody chose.
|
|
33
|
+
*/
|
|
34
|
+
export function exactCostOf(model: ModelId, usage: TokenUsage): Money {
|
|
35
|
+
const spec = modelSpec(model);
|
|
36
|
+
const { currency } = spec.inputPerMillion;
|
|
37
|
+
const scale = costScale(currency);
|
|
38
|
+
return {
|
|
39
|
+
minor: toSafe(ceilDiv(units(spec, usage, scale), TWENTIETHS * MILLION)),
|
|
40
|
+
currency,
|
|
41
|
+
scale,
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Cost of one call in the currency's own minor units — the figure every existing reader of
|
|
47
|
+
* `cost.minor` means. Rounded UP: a fraction we round away is money the framework silently
|
|
48
|
+
* absorbs, and under-reporting spend defeats a budget. Rounded ONCE, over the whole call: the
|
|
49
|
+
* input and output halves were each rounded up before, so a call worth a tenth of a cent read two.
|
|
50
|
+
*/
|
|
51
|
+
export function costOf(model: ModelId, usage: TokenUsage): Money {
|
|
52
|
+
const spec = modelSpec(model);
|
|
53
|
+
const { currency } = spec.inputPerMillion;
|
|
54
|
+
const total = units(spec, usage, costScale(currency));
|
|
55
|
+
return { minor: toSafe(ceilDiv(total, TWENTIETHS * MILLION * MICRO)), currency };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** An exact cost (or any finer `Money`) rounded up to its currency's minor unit. */
|
|
59
|
+
export function roundUpCost(exact: Money): Money {
|
|
60
|
+
const from = exact.scale ?? exponentOf(exact.currency);
|
|
61
|
+
const drop = 10n ** BigInt(Math.max(0, from - exponentOf(exact.currency)));
|
|
62
|
+
return { minor: toSafe(ceilDiv(BigInt(exact.minor), drop)), currency: exact.currency };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** `usage` priced, in twentieths of (a unit at `scale`) per million tokens — before any division. */
|
|
66
|
+
function units(spec: ModelSpec, usage: TokenUsage, scale: number): bigint {
|
|
67
|
+
const input = minorAt(spec.inputPerMillion, scale);
|
|
68
|
+
// A cache read is 0.1x input (2/20) and a 5-minute write 1.25x (25/20) unless the row states
|
|
69
|
+
// its own — a vendor's 0.05x or 0.025x read is a row, which a fixed 0.1x overstated.
|
|
70
|
+
const read =
|
|
71
|
+
spec.cacheReadPerMillion === undefined
|
|
72
|
+
? input * 2n
|
|
73
|
+
: minorAt(spec.cacheReadPerMillion, scale) * TWENTIETHS;
|
|
74
|
+
const write =
|
|
75
|
+
spec.cacheWritePerMillion === undefined
|
|
76
|
+
? input * 25n
|
|
77
|
+
: minorAt(spec.cacheWritePerMillion, scale) * TWENTIETHS;
|
|
78
|
+
return (
|
|
79
|
+
count(usage.inputTokens) * input * TWENTIETHS +
|
|
80
|
+
count(usage.cacheReadTokens) * read +
|
|
81
|
+
count(usage.cacheWriteTokens) * write +
|
|
82
|
+
count(usage.outputTokens) * minorAt(spec.outputPerMillion, scale) * TWENTIETHS
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** A token count as a bigint. Usage is the provider's number: floored, and never negative. */
|
|
87
|
+
const count = (tokens: number): bigint =>
|
|
88
|
+
Number.isFinite(tokens) && tokens > 0 ? BigInt(Math.floor(tokens)) : 0n;
|
|
89
|
+
|
|
90
|
+
const ceilDiv = (numerator: bigint, denominator: bigint): bigint =>
|
|
91
|
+
(numerator + denominator - 1n) / denominator;
|
|
92
|
+
|
|
93
|
+
/** Back to the `number` `Money` carries. Past 2^53 minor units there is no honest answer. */
|
|
94
|
+
const toSafe = (value: bigint): number =>
|
|
95
|
+
value > BigInt(Number.MAX_SAFE_INTEGER) ? Number.MAX_SAFE_INTEGER : Number(value);
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* `a` against `b` as MONEY — negative, zero or positive — at the finer of their two scales. Never
|
|
99
|
+
* `a.minor - b.minor`: `{ minor: 500, scale: 3 }` is fifty cents, and read by `minor` alone it
|
|
100
|
+
* outranked four dollars.
|
|
101
|
+
*/
|
|
102
|
+
export function compareMoney(a: Money, b: Money): number {
|
|
103
|
+
const scale = commonScale(a, b);
|
|
104
|
+
const left = minorAt(a, scale);
|
|
105
|
+
const right = minorAt(b, scale);
|
|
106
|
+
return left < right ? -1 : left > right ? 1 : 0;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** Two amounts of one currency as one, at the finer scale — stated only when either stated one. */
|
|
110
|
+
export function addMoney(a: Money, b: Money): Money {
|
|
111
|
+
const scale = commonScale(a, b);
|
|
112
|
+
const minor = toSafe(minorAt(a, scale) + minorAt(b, scale));
|
|
113
|
+
return a.scale === undefined && b.scale === undefined
|
|
114
|
+
? { minor, currency: a.currency }
|
|
115
|
+
: { minor, currency: a.currency, scale };
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* A cost as a `Provider` reported it, made bookable. The built-in providers price in integers, but
|
|
120
|
+
* `Provider` is an interface an app implements, and its `cost.minor` was summed as a plain number
|
|
121
|
+
* until the ledger went exact: a fraction was accepted. So a fraction is ROUNDED UP (never down:
|
|
122
|
+
* under-reporting spend defeats a budget), and a value that is no amount at all — `NaN`, an
|
|
123
|
+
* infinity, a negative, which a ledger would read as a credit — is booked as zero. Never a throw:
|
|
124
|
+
* this runs after the provider was paid, and a bookkeeping error must not cost the answer.
|
|
125
|
+
*/
|
|
126
|
+
export function bookableCost(cost: Money): Money {
|
|
127
|
+
const minor = Number.isFinite(cost.minor) && cost.minor > 0 ? Math.ceil(cost.minor) : 0;
|
|
128
|
+
const scale = Number.isInteger(cost.scale) ? cost.scale : undefined;
|
|
129
|
+
return scale === undefined
|
|
130
|
+
? { minor, currency: cost.currency }
|
|
131
|
+
: { minor, currency: cost.currency, scale };
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* `cost` in units of the ledger's scale (`costScale`), as a bigint. A coarser cost is widened
|
|
136
|
+
* exactly; a FINER one — a provider pricing to twelve places — is rounded UP to the ledger's
|
|
137
|
+
* digit, here, in bigint: `minorAt` only widens and throws on the rest, and `rescale` answers a
|
|
138
|
+
* `Money` whose `minor` can leave the safe range.
|
|
139
|
+
*/
|
|
140
|
+
export function ledgerUnits(cost: Money): bigint {
|
|
141
|
+
const booked = bookableCost(cost);
|
|
142
|
+
const target = costScale(booked.currency);
|
|
143
|
+
const from = moneyScale(booked);
|
|
144
|
+
if (from <= target) return minorAt(booked, target);
|
|
145
|
+
return ceilDiv(BigInt(booked.minor), 10n ** BigInt(from - target));
|
|
146
|
+
}
|
package/src/echo-provider.ts
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
// from ./provider, which owns the real Messages API, because a double and the socket it stands in
|
|
3
3
|
// for are two jobs — and the request half had reached its line ceiling holding both.
|
|
4
4
|
|
|
5
|
+
import { costOf, exactCostOf } from './cost';
|
|
5
6
|
import { resolveModel } from './model-resolve';
|
|
6
7
|
import type { ModelId } from './models';
|
|
7
8
|
import { modelIds } from './models';
|
|
@@ -13,7 +14,7 @@ import type {
|
|
|
13
14
|
StreamChunk,
|
|
14
15
|
TokenUsage,
|
|
15
16
|
} from './provider';
|
|
16
|
-
import {
|
|
17
|
+
import { estimateTextTokens, estimateTokens, messageText } from './provider';
|
|
17
18
|
|
|
18
19
|
export interface EchoProviderInput {
|
|
19
20
|
/** Fixed replies keyed by the last user message, for eval fixtures. */
|
|
@@ -69,6 +70,7 @@ export class EchoProvider implements Provider {
|
|
|
69
70
|
stopDetails: undefined,
|
|
70
71
|
usage,
|
|
71
72
|
cost: costOf(model, usage),
|
|
73
|
+
costExact: exactCostOf(model, usage),
|
|
72
74
|
};
|
|
73
75
|
}
|
|
74
76
|
|
package/src/errors-retry.ts
CHANGED
|
@@ -38,6 +38,20 @@ export const AI_ERROR_RETRY = {
|
|
|
38
38
|
// be both, and registering the code here is what makes the override honoured: core reads an
|
|
39
39
|
// instance `terminal` on an UNREGISTERED code as unclassified.
|
|
40
40
|
X_AI_PROVIDER_UNAVAILABLE: 'retryable',
|
|
41
|
+
// The two stops an unattended `agent()` run makes, REGISTERED terminal — the deliberate decision
|
|
42
|
+
// the paragraph above leaves to whoever makes it. A run stopped for repeating itself repeats
|
|
43
|
+
// itself again, and a run that outlived its declared deadline is the same run against the same
|
|
44
|
+
// deadline: unclassified, an `agentJob` with `attempts: 3` paid for it three times.
|
|
45
|
+
X_AGENT_TOOL_LOOP: 'terminal',
|
|
46
|
+
X_AGENT_DEADLINE: 'terminal',
|
|
47
|
+
// The four a job otherwise pays for once per attempt, for the same answer: the ceiling does not
|
|
48
|
+
// move between attempts, a schema two answers failed is a prompt to edit, arguments a model
|
|
49
|
+
// broke on this request are a schema to simplify, and a refused handle is refused again. The
|
|
50
|
+
// caller changes the declaration or starts a new run; retrying only re-bills.
|
|
51
|
+
X_LLM_TRUNCATED: 'terminal',
|
|
52
|
+
X_LLM_OUTPUT_INVALID: 'terminal',
|
|
53
|
+
X_LLM_TOOL_ARGUMENTS_INVALID: 'terminal',
|
|
54
|
+
X_AGENT_TRANSCRIPT_INVALID: 'terminal',
|
|
41
55
|
} as const satisfies Readonly<Partial<Record<AiErrorCode, ErrorRetry>>>;
|
|
42
56
|
|
|
43
57
|
// `Partial`, so the table may be a subset — but every key is still checked against the declared set,
|
package/src/errors.ts
CHANGED
|
@@ -30,6 +30,11 @@ export const AI_ERROR_CODES = [
|
|
|
30
30
|
'X_VECTOR_UNSCOPED',
|
|
31
31
|
'X_AI_CONTENT_UNSUPPORTED',
|
|
32
32
|
'X_AI_MODEL_UNRESOLVED',
|
|
33
|
+
'X_AGENT_TOOL_LOOP',
|
|
34
|
+
'X_AGENT_DEADLINE',
|
|
35
|
+
'X_LLM_TOOL_ARGUMENTS_INVALID',
|
|
36
|
+
'X_AGENT_TRANSCRIPT_INVALID',
|
|
37
|
+
'X_AI_PROVIDER_UNEXPECTED',
|
|
33
38
|
] as const;
|
|
34
39
|
|
|
35
40
|
export type AiErrorCode = (typeof AI_ERROR_CODES)[number];
|
|
@@ -48,7 +53,8 @@ export const AI_ERROR_TITLES: Readonly<Record<AiErrorCode, string>> = {
|
|
|
48
53
|
X_LLM_TRUNCATED: 'the answer hit its maxTokens ceiling before it was complete',
|
|
49
54
|
X_LLM_STREAM_INVALID: 'a streamed answer failed its output schema, and a stream cannot repair',
|
|
50
55
|
X_AGENT_MAX_TURNS: 'an agent hit its turn ceiling without answering',
|
|
51
|
-
X_AGENT_TOOL_UNEXPOSED:
|
|
56
|
+
X_AGENT_TOOL_UNEXPOSED:
|
|
57
|
+
'an agent lists a tool that is neither in-process nor an MCP-exposed action',
|
|
52
58
|
X_HIVE_EMPTY: 'a hive split produced no members, so the run did nothing',
|
|
53
59
|
X_EVAL_THRESHOLD: 'an eval scored below its tolerance',
|
|
54
60
|
X_EVAL_BASELINE_MISSING: 'an eval has no recorded baseline to gate against',
|
|
@@ -61,6 +67,12 @@ export const AI_ERROR_TITLES: Readonly<Record<AiErrorCode, string>> = {
|
|
|
61
67
|
X_VECTOR_UNSCOPED: 'a vector store was read with no tenant bound inside an org request',
|
|
62
68
|
X_AI_CONTENT_UNSUPPORTED: 'an image or document block the role, model or wire format cannot take',
|
|
63
69
|
X_AI_MODEL_UNRESOLVED: 'a model call named no model',
|
|
70
|
+
X_AGENT_TOOL_LOOP: 'an agent repeated one tool call with one result and was stopped',
|
|
71
|
+
X_AGENT_DEADLINE: 'an agent ran past its declared deadline and was stopped',
|
|
72
|
+
X_LLM_TOOL_ARGUMENTS_INVALID: 'a finished turn called a tool with arguments that are not JSON',
|
|
73
|
+
X_AGENT_TRANSCRIPT_INVALID: 'an agent was asked to continue from a transcript it cannot read',
|
|
74
|
+
X_AI_PROVIDER_UNEXPECTED:
|
|
75
|
+
'a provider ended a turn with an unknown reason and an incomplete answer',
|
|
64
76
|
};
|
|
65
77
|
|
|
66
78
|
// Titles must be registered for `format()` to render the contract's first line. Unconditional and
|
|
@@ -202,9 +214,20 @@ export class LlmStreamInvalidError extends UltimateError {
|
|
|
202
214
|
* bad roll — a third attempt only spends money, so this throws instead of looping.
|
|
203
215
|
*/
|
|
204
216
|
export class LlmOutputInvalidError extends UltimateError {
|
|
205
|
-
constructor(input: {
|
|
217
|
+
constructor(input: {
|
|
218
|
+
prompt: string;
|
|
219
|
+
attempts: number;
|
|
220
|
+
issues: string;
|
|
221
|
+
/** The last answer's issues, one per entry — what a job stores; `issues` is the cause's text. */
|
|
222
|
+
list?: readonly string[];
|
|
223
|
+
}) {
|
|
206
224
|
super({
|
|
207
225
|
code: 'X_LLM_OUTPUT_INVALID',
|
|
226
|
+
meta: {
|
|
227
|
+
prompt: input.prompt,
|
|
228
|
+
attempts: input.attempts,
|
|
229
|
+
issues: input.list ?? [input.issues],
|
|
230
|
+
},
|
|
208
231
|
cause:
|
|
209
232
|
`prompt "${input.prompt}" returned output failing its schema on all ` +
|
|
210
233
|
`${input.attempts} attempts: ${input.issues}`,
|
|
@@ -250,7 +273,7 @@ export class AgentToolUnexposedError extends UltimateError {
|
|
|
250
273
|
super({
|
|
251
274
|
code: 'X_AGENT_TOOL_UNEXPOSED',
|
|
252
275
|
cause: `agent "${input.agent}" lists tools no MCP surface exposes: ${input.tools.join(', ')}`,
|
|
253
|
-
fix:
|
|
276
|
+
fix: "inProcessTool({ name, description, input, execute }) # for a tool only this agent may call — or add mcp: { expose: true } to the action named in cause to offer it to MCP clients too, or drop it from the agent's tools",
|
|
254
277
|
});
|
|
255
278
|
}
|
|
256
279
|
}
|
|
@@ -291,22 +314,6 @@ export class LlmRefusedError extends UltimateError {
|
|
|
291
314
|
}
|
|
292
315
|
}
|
|
293
316
|
|
|
294
|
-
/**
|
|
295
|
-
* The answer was cut off at the enforced ceiling and what arrived does not satisfy the schema.
|
|
296
|
-
* Thrown instead of the repair turn on purpose: the ceiling does not move between attempts, so
|
|
297
|
-
* a second answer truncates at exactly the same place and only spends money.
|
|
298
|
-
*/
|
|
299
|
-
export class LlmTruncatedError extends UltimateError {
|
|
300
|
-
constructor(input: { prompt: string; maxTokens: number }) {
|
|
301
|
-
super({
|
|
302
|
-
code: 'X_LLM_TRUNCATED',
|
|
303
|
-
cause: `prompt "${input.prompt}" was cut off at its ${input.maxTokens}-token ceiling`,
|
|
304
|
-
fix: `set maxTokens: ${input.maxTokens * 2} on the llm() declaration, or drop fields from its output schema`,
|
|
305
|
-
});
|
|
306
|
-
}
|
|
307
|
-
}
|
|
308
|
-
|
|
309
|
-
/** A prompt was requested at a version whose content hash does not match the registry. */
|
|
310
317
|
export class AiPromptVersionError extends UltimateError {
|
|
311
318
|
constructor(input: { id: string; requested: string; available: readonly string[] }) {
|
|
312
319
|
super({
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
|
|
5
5
|
import type { Logger } from '@ultimat3/core';
|
|
6
6
|
import { hasPublicCause, isUltimateError, stringField } from '@ultimat3/core';
|
|
7
|
-
import { statusFor } from '@ultimat3/http';
|
|
7
|
+
import { declaredStatusFor, statusFor } from '@ultimat3/http';
|
|
8
8
|
|
|
9
9
|
/**
|
|
10
10
|
* What may be shown. `code` is absent for an uncoded throw — a foreign `.message` is the server's
|
|
@@ -46,6 +46,42 @@ export function discloseFailure(
|
|
|
46
46
|
};
|
|
47
47
|
}
|
|
48
48
|
|
|
49
|
+
/**
|
|
50
|
+
* The verdict for a tool that runs IN PROCESS: the app's own code, called by the agent it was
|
|
51
|
+
* written for. Its typed refusal — an `UltimateError` built in this process, under a code NOBODY
|
|
52
|
+
* gave an HTTP status — is shown whole: code, cause, and the fix (the caller's when it has one).
|
|
53
|
+
* That is the one case the general rule gets wrong for this reader: an unmapped app code defaults
|
|
54
|
+
* to 500, a 500's cause is hidden, and a model that cannot read why its call was refused makes
|
|
55
|
+
* the same call again.
|
|
56
|
+
*
|
|
57
|
+
* Everything else keeps the general verdict, so nothing internal rides along: an uncoded throw
|
|
58
|
+
* (a `TypeError`, a driver's own error) shows nothing; a code the framework or the app DECLARED a
|
|
59
|
+
* status for follows that status — a driver's `X_DB_STATEMENT_FAILED` stays hidden; and a
|
|
60
|
+
* look-alike object carrying a `code` is not a typed refusal. Never a stack: only authored fields.
|
|
61
|
+
*/
|
|
62
|
+
export function discloseOwnFailure(
|
|
63
|
+
error: unknown,
|
|
64
|
+
logger: Logger | undefined,
|
|
65
|
+
where: string,
|
|
66
|
+
): DisclosedFailure {
|
|
67
|
+
const code = isUltimateError(error) ? stringField(error, 'code') : undefined;
|
|
68
|
+
// The model is a REMOTE reader, so the fix it gets is the caller's wherever one was written —
|
|
69
|
+
// `x actions show …` is no use to it.
|
|
70
|
+
const callerFix = code === undefined ? undefined : stringField(error, 'callerFix');
|
|
71
|
+
if (code === undefined || declaredStatusFor(code) !== undefined) {
|
|
72
|
+
const shown = discloseFailure(error, logger, where);
|
|
73
|
+
return shown.cause === undefined || callerFix === undefined
|
|
74
|
+
? shown
|
|
75
|
+
: { ...shown, fix: callerFix };
|
|
76
|
+
}
|
|
77
|
+
const fix = callerFix ?? stringField(error, 'fix');
|
|
78
|
+
return {
|
|
79
|
+
code,
|
|
80
|
+
cause: stringField(error, 'cause') ?? 'unknown',
|
|
81
|
+
fix: fix === undefined || fix === '' ? undefined : fix,
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
|
|
49
85
|
/**
|
|
50
86
|
* A stack's FRAMES and nothing else: every line that is a frame (` at …`), none that is not.
|
|
51
87
|
* The message is the first line — and, when it spans several, the lines after it until the first
|