@ultimat3/ai 27.9.0 → 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 +104 -108
- package/README.md +21 -5
- package/package.json +11 -11
- package/src/agent-continue.ts +434 -0
- package/src/agent-declared.ts +157 -0
- package/src/agent-def.ts +28 -5
- package/src/agent-errors.ts +20 -1
- package/src/agent-turn.ts +54 -0
- package/src/agent-usage.ts +49 -13
- package/src/agent.ts +157 -193
- package/src/answer-errors.ts +88 -0
- package/src/billed.ts +87 -0
- package/src/budget.ts +37 -10
- package/src/cost.ts +146 -0
- package/src/echo-provider.ts +3 -1
- package/src/errors-retry.ts +8 -0
- package/src/errors.ts +22 -19
- package/src/failure-disclosure.ts +37 -1
- package/src/gateway.ts +53 -7
- package/src/in-process-tool.ts +77 -0
- package/src/index.ts +29 -3
- package/src/llm-def.ts +97 -0
- package/src/llm.ts +86 -74
- package/src/models.ts +45 -1
- package/src/openai-body.ts +6 -3
- package/src/openai-provider.ts +11 -2
- package/src/openai-wire.ts +168 -62
- package/src/provider.ts +32 -34
- 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
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
// The two failures of an answer that ARRIVED and cannot be used: cut off at the ceiling, or a tool
|
|
2
|
+
// call whose arguments do not parse. Apart from ./errors only because one file has one job and
|
|
3
|
+
// that catalogue is at its line ceiling — the split `hive-errors.ts` and `agent-errors.ts` made.
|
|
4
|
+
// The codes, their titles and the single `registerErrorCodes` call stay in ./errors.
|
|
5
|
+
|
|
6
|
+
import { UltimateError } from '@ultimat3/core';
|
|
7
|
+
import type { Billed } from './billed';
|
|
8
|
+
import { BILLED_META } from './billed';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* The answer was cut off at the enforced ceiling. Thrown BEFORE the answer is read, and instead of
|
|
12
|
+
* a repair turn: a cut-off `respond` call arrives with no arguments, which both wires read as `{}`
|
|
13
|
+
* and an `output` of optional keys accepts — and the ceiling does not move between attempts, so a
|
|
14
|
+
* second answer truncates at exactly the same place and only spends money.
|
|
15
|
+
*/
|
|
16
|
+
export class LlmTruncatedError extends UltimateError {
|
|
17
|
+
constructor(input: {
|
|
18
|
+
prompt: string;
|
|
19
|
+
maxTokens: number;
|
|
20
|
+
/** The factory whose declaration carries the ceiling. */
|
|
21
|
+
subject?: 'llm' | 'agent';
|
|
22
|
+
}) {
|
|
23
|
+
super({
|
|
24
|
+
code: 'X_LLM_TRUNCATED',
|
|
25
|
+
cause: `prompt "${input.prompt}" was cut off at its ${input.maxTokens}-token ceiling`,
|
|
26
|
+
fix: `maxTokens: ${input.maxTokens * 2} # on the ${input.subject ?? 'llm'}() declaration — or drop fields from its output schema`,
|
|
27
|
+
meta: { prompt: input.prompt, maxTokens: input.maxTokens },
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* The model finished its turn and the arguments of a tool call it made are not JSON. Not a
|
|
34
|
+
* transport failure: the provider answered, the answer was billed, and asking the next provider
|
|
35
|
+
* (or the same one three more times) buys the same request again. So it is its own code, the
|
|
36
|
+
* gateway does not retry it, and it carries the bill.
|
|
37
|
+
*
|
|
38
|
+
* The cause never quotes the arguments. They are the model's text, and for `respond` they are the
|
|
39
|
+
* answer — which is where whatever the prompt loaded ends up.
|
|
40
|
+
*/
|
|
41
|
+
export class LlmToolArgumentsError extends UltimateError {
|
|
42
|
+
constructor(input: {
|
|
43
|
+
provider: string;
|
|
44
|
+
model: string;
|
|
45
|
+
tool: string;
|
|
46
|
+
chars: number;
|
|
47
|
+
billed: Billed;
|
|
48
|
+
/** Fragments with no index and no id: two calls merged, not one call broken. */
|
|
49
|
+
unkeyed?: boolean;
|
|
50
|
+
}) {
|
|
51
|
+
super({
|
|
52
|
+
code: 'X_LLM_TOOL_ARGUMENTS_INVALID',
|
|
53
|
+
cause:
|
|
54
|
+
input.unkeyed === true
|
|
55
|
+
? `provider "${input.provider}" streamed tool-call fragments for model "${input.model}" with no index and no id, and more than one of them named a tool — the calls cannot be told apart, so none was run`
|
|
56
|
+
: `model "${input.model}" on provider "${input.provider}" finished its turn calling tool "${input.tool}" with ${input.chars} characters of arguments that are not JSON`,
|
|
57
|
+
fix: `maxTokens: 8192 # on the declaration, if the arguments were long — and shorten or split the string fields in the input schema of "${input.tool}", the place a model breaks JSON. Terminal for a job: to run the same request once more, catch this code and re-enqueue it deliberately`,
|
|
58
|
+
meta: {
|
|
59
|
+
provider: input.provider,
|
|
60
|
+
model: input.model,
|
|
61
|
+
tool: input.tool,
|
|
62
|
+
[BILLED_META]: input.billed,
|
|
63
|
+
},
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* A chat-completions endpoint finished a turn with a reason this build does not know, and the turn
|
|
70
|
+
* is not whole — a tool call whose arguments do not parse — or the reason was `error`. Not
|
|
71
|
+
* truncation (nothing says the ceiling was reached) and not a transport failure (the endpoint
|
|
72
|
+
* answered, and billed): its own code, carrying the reason and the bill.
|
|
73
|
+
*/
|
|
74
|
+
export class AiProviderUnexpectedError extends UltimateError {
|
|
75
|
+
constructor(input: { provider: string; model: string; reason: string; billed: Billed }) {
|
|
76
|
+
super({
|
|
77
|
+
code: 'X_AI_PROVIDER_UNEXPECTED',
|
|
78
|
+
cause: `provider "${input.provider}" finished a turn of model "${input.model}" with the finish reason ${JSON.stringify(input.reason.slice(0, 64))}, which this build does not know, and the answer is not complete`,
|
|
79
|
+
fix: "x doctor --json # then check what the endpoint means by the reason in cause: a truncation needs a higher maxTokens on the declaration, anything else is the endpoint's to explain",
|
|
80
|
+
meta: {
|
|
81
|
+
provider: input.provider,
|
|
82
|
+
model: input.model,
|
|
83
|
+
reason: input.reason.slice(0, 64),
|
|
84
|
+
[BILLED_META]: input.billed,
|
|
85
|
+
},
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
}
|
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';
|
|
@@ -187,7 +188,10 @@ export interface BudgetLedgerInput {
|
|
|
187
188
|
|
|
188
189
|
export interface BudgetReport {
|
|
189
190
|
readonly requestTokens: number;
|
|
191
|
+
/** Everything recorded, rounded up ONCE to the currency's minor unit. */
|
|
190
192
|
readonly cost: Money;
|
|
193
|
+
/** The same sum exactly, at `costScale(currency)` — micro-units of the minor unit. */
|
|
194
|
+
readonly costExact: Money;
|
|
191
195
|
readonly limits: BudgetLimits;
|
|
192
196
|
readonly actorSpent: number;
|
|
193
197
|
readonly orgSpent: number;
|
|
@@ -199,7 +203,8 @@ export class BudgetLedger {
|
|
|
199
203
|
private readonly orgKey: string | undefined;
|
|
200
204
|
private readonly store: BudgetStore;
|
|
201
205
|
private requestTokens = 0;
|
|
202
|
-
|
|
206
|
+
/** Summed EXACTLY, in micro-units of the minor unit, and rounded up once — when it is read. */
|
|
207
|
+
private costMicro = 0n;
|
|
203
208
|
private readonly currency: string;
|
|
204
209
|
/**
|
|
205
210
|
* The ledger this one was `derive`d from, or `undefined` for a scope's root. Set by `derive`
|
|
@@ -335,8 +340,11 @@ export class BudgetLedger {
|
|
|
335
340
|
async record(usage: TokenUsage, cost: Money, reservation?: BudgetReservation): Promise<void> {
|
|
336
341
|
// Up the chain, because `derive` copies the currency: a scope's reported cost is its own
|
|
337
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);
|
|
338
346
|
for (let l: BudgetLedger | undefined = this; l !== undefined; l = l.parent) {
|
|
339
|
-
l.
|
|
347
|
+
l.costMicro += micro;
|
|
340
348
|
}
|
|
341
349
|
await this.debit(totalTokens(usage) - (reservation?.tokens ?? 0));
|
|
342
350
|
}
|
|
@@ -374,13 +382,23 @@ export class BudgetLedger {
|
|
|
374
382
|
async report(): Promise<BudgetReport> {
|
|
375
383
|
return {
|
|
376
384
|
requestTokens: this.requestTokens,
|
|
377
|
-
cost:
|
|
385
|
+
cost: roundUpCost(this.exactCost()),
|
|
386
|
+
costExact: this.exactCost(),
|
|
378
387
|
limits: this.limits,
|
|
379
388
|
actorSpent: this.actorKey === undefined ? 0 : await this.store.spent(this.actorKey),
|
|
380
389
|
orgSpent: this.orgKey === undefined ? 0 : await this.store.spent(this.orgKey),
|
|
381
390
|
};
|
|
382
391
|
}
|
|
383
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
|
+
|
|
384
402
|
private assertScope(scope: string, limit: number | undefined, spent: number, want: number): void {
|
|
385
403
|
if (limit === undefined) return;
|
|
386
404
|
const remaining = limit - spent;
|
|
@@ -394,13 +412,21 @@ export class BudgetLedger {
|
|
|
394
412
|
const limit = this.limits.costPerCall;
|
|
395
413
|
if (limit === undefined) return;
|
|
396
414
|
assertSameCurrency(limit, cost);
|
|
397
|
-
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));
|
|
398
420
|
throw new AiBudgetExceededError({
|
|
399
421
|
scope: 'costPerCall',
|
|
400
|
-
requested: cost
|
|
401
|
-
remaining:
|
|
402
|
-
limit:
|
|
403
|
-
|
|
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}`,
|
|
404
430
|
});
|
|
405
431
|
}
|
|
406
432
|
}
|
|
@@ -456,7 +482,8 @@ function tighterMoney(a: Money | undefined, b: Money | undefined): Money | undef
|
|
|
456
482
|
if (a === undefined) return b;
|
|
457
483
|
if (b === undefined) return a;
|
|
458
484
|
assertSameCurrency(a, b);
|
|
459
|
-
|
|
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;
|
|
460
487
|
}
|
|
461
488
|
|
|
462
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
|
@@ -44,6 +44,14 @@ export const AI_ERROR_RETRY = {
|
|
|
44
44
|
// deadline: unclassified, an `agentJob` with `attempts: 3` paid for it three times.
|
|
45
45
|
X_AGENT_TOOL_LOOP: 'terminal',
|
|
46
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',
|
|
47
55
|
} as const satisfies Readonly<Partial<Record<AiErrorCode, ErrorRetry>>>;
|
|
48
56
|
|
|
49
57
|
// `Partial`, so the table may be a subset — but every key is still checked against the declared set,
|
package/src/errors.ts
CHANGED
|
@@ -32,6 +32,9 @@ export const AI_ERROR_CODES = [
|
|
|
32
32
|
'X_AI_MODEL_UNRESOLVED',
|
|
33
33
|
'X_AGENT_TOOL_LOOP',
|
|
34
34
|
'X_AGENT_DEADLINE',
|
|
35
|
+
'X_LLM_TOOL_ARGUMENTS_INVALID',
|
|
36
|
+
'X_AGENT_TRANSCRIPT_INVALID',
|
|
37
|
+
'X_AI_PROVIDER_UNEXPECTED',
|
|
35
38
|
] as const;
|
|
36
39
|
|
|
37
40
|
export type AiErrorCode = (typeof AI_ERROR_CODES)[number];
|
|
@@ -50,7 +53,8 @@ export const AI_ERROR_TITLES: Readonly<Record<AiErrorCode, string>> = {
|
|
|
50
53
|
X_LLM_TRUNCATED: 'the answer hit its maxTokens ceiling before it was complete',
|
|
51
54
|
X_LLM_STREAM_INVALID: 'a streamed answer failed its output schema, and a stream cannot repair',
|
|
52
55
|
X_AGENT_MAX_TURNS: 'an agent hit its turn ceiling without answering',
|
|
53
|
-
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',
|
|
54
58
|
X_HIVE_EMPTY: 'a hive split produced no members, so the run did nothing',
|
|
55
59
|
X_EVAL_THRESHOLD: 'an eval scored below its tolerance',
|
|
56
60
|
X_EVAL_BASELINE_MISSING: 'an eval has no recorded baseline to gate against',
|
|
@@ -65,6 +69,10 @@ export const AI_ERROR_TITLES: Readonly<Record<AiErrorCode, string>> = {
|
|
|
65
69
|
X_AI_MODEL_UNRESOLVED: 'a model call named no model',
|
|
66
70
|
X_AGENT_TOOL_LOOP: 'an agent repeated one tool call with one result and was stopped',
|
|
67
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',
|
|
68
76
|
};
|
|
69
77
|
|
|
70
78
|
// Titles must be registered for `format()` to render the contract's first line. Unconditional and
|
|
@@ -206,9 +214,20 @@ export class LlmStreamInvalidError extends UltimateError {
|
|
|
206
214
|
* bad roll — a third attempt only spends money, so this throws instead of looping.
|
|
207
215
|
*/
|
|
208
216
|
export class LlmOutputInvalidError extends UltimateError {
|
|
209
|
-
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
|
+
}) {
|
|
210
224
|
super({
|
|
211
225
|
code: 'X_LLM_OUTPUT_INVALID',
|
|
226
|
+
meta: {
|
|
227
|
+
prompt: input.prompt,
|
|
228
|
+
attempts: input.attempts,
|
|
229
|
+
issues: input.list ?? [input.issues],
|
|
230
|
+
},
|
|
212
231
|
cause:
|
|
213
232
|
`prompt "${input.prompt}" returned output failing its schema on all ` +
|
|
214
233
|
`${input.attempts} attempts: ${input.issues}`,
|
|
@@ -254,7 +273,7 @@ export class AgentToolUnexposedError extends UltimateError {
|
|
|
254
273
|
super({
|
|
255
274
|
code: 'X_AGENT_TOOL_UNEXPOSED',
|
|
256
275
|
cause: `agent "${input.agent}" lists tools no MCP surface exposes: ${input.tools.join(', ')}`,
|
|
257
|
-
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",
|
|
258
277
|
});
|
|
259
278
|
}
|
|
260
279
|
}
|
|
@@ -295,22 +314,6 @@ export class LlmRefusedError extends UltimateError {
|
|
|
295
314
|
}
|
|
296
315
|
}
|
|
297
316
|
|
|
298
|
-
/**
|
|
299
|
-
* The answer was cut off at the enforced ceiling and what arrived does not satisfy the schema.
|
|
300
|
-
* Thrown instead of the repair turn on purpose: the ceiling does not move between attempts, so
|
|
301
|
-
* a second answer truncates at exactly the same place and only spends money.
|
|
302
|
-
*/
|
|
303
|
-
export class LlmTruncatedError extends UltimateError {
|
|
304
|
-
constructor(input: { prompt: string; maxTokens: number }) {
|
|
305
|
-
super({
|
|
306
|
-
code: 'X_LLM_TRUNCATED',
|
|
307
|
-
cause: `prompt "${input.prompt}" was cut off at its ${input.maxTokens}-token ceiling`,
|
|
308
|
-
fix: `set maxTokens: ${input.maxTokens * 2} on the llm() declaration, or drop fields from its output schema`,
|
|
309
|
-
});
|
|
310
|
-
}
|
|
311
|
-
}
|
|
312
|
-
|
|
313
|
-
/** A prompt was requested at a version whose content hash does not match the registry. */
|
|
314
317
|
export class AiPromptVersionError extends UltimateError {
|
|
315
318
|
constructor(input: { id: string; requested: string; available: readonly string[] }) {
|
|
316
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
|