@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
package/src/gateway.ts
CHANGED
|
@@ -16,7 +16,8 @@ import {
|
|
|
16
16
|
stringField,
|
|
17
17
|
} from '@ultimat3/core';
|
|
18
18
|
import type { Money } from '@ultimat3/money';
|
|
19
|
-
import
|
|
19
|
+
import { billedOf } from './billed';
|
|
20
|
+
import type { BudgetKeys, BudgetLimits, BudgetReservation, BudgetStore } from './budget';
|
|
20
21
|
import {
|
|
21
22
|
assertFiniteLimits,
|
|
22
23
|
BudgetLedger,
|
|
@@ -28,6 +29,7 @@ import { AiProviderUnavailableError } from './errors';
|
|
|
28
29
|
import { resolveModel } from './model-resolve';
|
|
29
30
|
import type { ModelId } from './models';
|
|
30
31
|
import type { GenerateRequest, GenerateResult, Provider, StreamChunk } from './provider';
|
|
32
|
+
import type { SamplingWire } from './sampling';
|
|
31
33
|
|
|
32
34
|
/**
|
|
33
35
|
* Response cache. Structurally satisfied by `@ultimat3/cache`'s memo/LRU tiers; declared as
|
|
@@ -82,6 +84,8 @@ export interface ProviderGatewayInput {
|
|
|
82
84
|
}
|
|
83
85
|
|
|
84
86
|
export interface Gateway {
|
|
87
|
+
/** The wire formats of the providers serving `model` — what `llm.sampling.ignored` is read from. */
|
|
88
|
+
samplingWires?(model: ModelId): readonly SamplingWire[];
|
|
85
89
|
/**
|
|
86
90
|
* The model a call with none declared runs on — `providerGateway({ defaultModel })`. Read by
|
|
87
91
|
* `llm()` and `agent()` after the declaration and its prompt, so the app's choice is the one
|
|
@@ -186,11 +190,23 @@ class GatewayImpl implements Gateway {
|
|
|
186
190
|
try {
|
|
187
191
|
result = await this.attempt(model, (provider) => provider.generate(resolved));
|
|
188
192
|
} catch (error) {
|
|
189
|
-
// A call that never landed must not go on holding its reservation
|
|
190
|
-
|
|
193
|
+
// A call that never landed must not go on holding its reservation — and one that DID land
|
|
194
|
+
// and still failed (`billedOf`: the provider answered with something unusable) is recorded
|
|
195
|
+
// as what it cost. Releasing it told the ledger a billed call was free.
|
|
196
|
+
await settleFailure(ledger, reservation, error);
|
|
191
197
|
throw error;
|
|
192
198
|
}
|
|
193
|
-
|
|
199
|
+
// The provider was PAID by now. Nothing below may turn that answer into a failure: a ledger
|
|
200
|
+
// that cannot book it gives the reservation back and says so, and the answer is returned.
|
|
201
|
+
try {
|
|
202
|
+
await ledger?.record(result.usage, result.costExact ?? result.cost, reservation);
|
|
203
|
+
} catch (error) {
|
|
204
|
+
await ledger?.release(reservation).catch(() => undefined);
|
|
205
|
+
logger.warn('ai.budget.record_failed', {
|
|
206
|
+
provider: result.provider ?? 'unknown',
|
|
207
|
+
error: renderThrowable(error),
|
|
208
|
+
});
|
|
209
|
+
}
|
|
194
210
|
// A refusal is not an answer, so it is not cached. Storing one would keep serving a decision
|
|
195
211
|
// the classifier might not make twice, long after the prompt that provoked it was fixed.
|
|
196
212
|
if (result.stopReason !== 'refusal') await this.remember(cacheKey, result);
|
|
@@ -240,17 +256,33 @@ class GatewayImpl implements Gateway {
|
|
|
240
256
|
}
|
|
241
257
|
// Settled only once `record` has LANDED. Marking it first left a store that threw here
|
|
242
258
|
// holding the reservation and half the record — the `finally` saw a settled stream.
|
|
243
|
-
|
|
259
|
+
const { usage, cost, costExact } = chunk.result;
|
|
260
|
+
await ledger?.record(usage, costExact ?? cost, reservation);
|
|
244
261
|
settled = true;
|
|
245
262
|
yield { type: 'done', result: { ...chunk.result, provider: provider.name } };
|
|
246
263
|
}
|
|
264
|
+
} catch (error) {
|
|
265
|
+
// Thrown after the provider reported usage: the bill is recorded, exactly as `generate` does.
|
|
266
|
+
// Only when nothing settled yet — a provider that throws AFTER its `done` chunk was already
|
|
267
|
+
// recorded, and releasing again would credit the ledger a second time.
|
|
268
|
+
if (!settled) {
|
|
269
|
+
settled = true;
|
|
270
|
+
await settleFailure(ledger, reservation, error);
|
|
271
|
+
}
|
|
272
|
+
throw error;
|
|
247
273
|
} finally {
|
|
248
|
-
// A stream
|
|
249
|
-
//
|
|
274
|
+
// A stream its consumer abandoned never reached `done` — so nothing reconciled the
|
|
275
|
+
// reservation and it would hold the ceiling for the rest of the window.
|
|
250
276
|
if (!settled) await ledger?.release(reservation);
|
|
251
277
|
}
|
|
252
278
|
}
|
|
253
279
|
|
|
280
|
+
samplingWires(model: ModelId): readonly SamplingWire[] {
|
|
281
|
+
return this.config.providers
|
|
282
|
+
.filter((p) => p.models.includes(model))
|
|
283
|
+
.flatMap((p) => (p.wire === undefined ? [] : [p.wire]));
|
|
284
|
+
}
|
|
285
|
+
|
|
254
286
|
/** Whether any provider serving `model` would send a hinted request with a cache mark. */
|
|
255
287
|
private marks(model: ModelId): boolean {
|
|
256
288
|
return this.config.providers.some(
|
|
@@ -326,6 +358,17 @@ class GatewayImpl implements Gateway {
|
|
|
326
358
|
}
|
|
327
359
|
}
|
|
328
360
|
|
|
361
|
+
/** A failed call's reservation: reconciled against its bill when the failure carries one, else given back. */
|
|
362
|
+
async function settleFailure(
|
|
363
|
+
ledger: BudgetLedger | undefined,
|
|
364
|
+
reservation: BudgetReservation | undefined,
|
|
365
|
+
error: unknown,
|
|
366
|
+
): Promise<void> {
|
|
367
|
+
const billed = billedOf(error);
|
|
368
|
+
if (billed === undefined) return ledger?.release(reservation);
|
|
369
|
+
await ledger?.record(billed.usage, billed.cost, reservation);
|
|
370
|
+
}
|
|
371
|
+
|
|
329
372
|
/**
|
|
330
373
|
* The request's completion ceiling, screened at the one seam every model call in an app passes
|
|
331
374
|
* through — `llm()`, `agent()`, an eval, a judge and a hand-built `generate()` alike.
|
|
@@ -423,5 +466,8 @@ export function promptCacheKey(request: GenerateRequest): string {
|
|
|
423
466
|
thinking: request.thinking ?? 'adaptive',
|
|
424
467
|
tools: request.tools ?? [],
|
|
425
468
|
stop: request.stopSequences ?? [],
|
|
469
|
+
// Only when asked for: an absent knob must leave every key written before it existed as it was.
|
|
470
|
+
...(request.temperature === undefined ? {} : { temperature: request.temperature }),
|
|
471
|
+
...(request.seed === undefined ? {} : { seed: request.seed }),
|
|
426
472
|
});
|
|
427
473
|
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
// Single responsibility: `inProcessTool()` — a tool that exists only for an agent in this
|
|
2
|
+
// process. A name, a description, an input schema and an `execute`; nothing an MCP surface, an
|
|
3
|
+
// HTTP route, `openapi.json` or the manifest ever sees.
|
|
4
|
+
//
|
|
5
|
+
// Not a ninth primitive and not an action: it is a function the agent's own action may call, as
|
|
6
|
+
// code in that action's `handle` would be. The AGENT is the operation with a policy; this runs as
|
|
7
|
+
// `ctx.actor` inside it, and anything it reaches that needs its own authorization is an `action()`
|
|
8
|
+
// it calls — whose policy then decides, as always.
|
|
9
|
+
//
|
|
10
|
+
// It exists because the other way to give an agent a tool was to declare an action
|
|
11
|
+
// `mcp: { expose: true }` — the same flag that serves it to every external MCP client. A tool that
|
|
12
|
+
// must never be an MCP tool had to claim it was one.
|
|
13
|
+
|
|
14
|
+
import { InputInvalidError } from '@ultimat3/action';
|
|
15
|
+
import type { Actor } from '@ultimat3/core';
|
|
16
|
+
import type { InferOutput, StandardSchemaV1 } from '@ultimat3/schema';
|
|
17
|
+
import { formatIssues, toWireSchema, validateAsync } from '@ultimat3/schema';
|
|
18
|
+
import { AiRequestInvalidError } from './errors';
|
|
19
|
+
import { RESPOND } from './respond';
|
|
20
|
+
import type { ProjectableAction } from './tools';
|
|
21
|
+
|
|
22
|
+
export interface InProcessToolDef<TInput extends StandardSchemaV1> {
|
|
23
|
+
/** What the model calls it. Letters, digits, `_` and `-`, at most 64 — what every provider takes. */
|
|
24
|
+
readonly name: string;
|
|
25
|
+
/** What the model reads to decide when to call it. Required: a tool with none is never chosen well. */
|
|
26
|
+
readonly description: string;
|
|
27
|
+
/** Parses what the model sent before `execute` sees it; projected as the tool's input schema. */
|
|
28
|
+
readonly input: TInput;
|
|
29
|
+
/**
|
|
30
|
+
* Runs as the request's own actor — never one the model named. Throw an `UltimateError` to refuse:
|
|
31
|
+
* its code, cause and fix are what the model reads (`discloseErrors`).
|
|
32
|
+
*/
|
|
33
|
+
execute(args: { readonly input: InferOutput<TInput>; readonly actor: Actor }): unknown;
|
|
34
|
+
/** `false` withholds this tool's refusals from the model as a projected tool's are. Default `true`. */
|
|
35
|
+
readonly discloseErrors?: boolean;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const NAME = /^[A-Za-z0-9_-]{1,64}$/;
|
|
39
|
+
|
|
40
|
+
/** A tool for `agent({ tools })` that is this process's alone. Declared once, at module scope. */
|
|
41
|
+
export function inProcessTool<TInput extends StandardSchemaV1>(
|
|
42
|
+
def: InProcessToolDef<TInput>,
|
|
43
|
+
): ProjectableAction {
|
|
44
|
+
if (!NAME.test(def.name) || def.name === RESPOND) {
|
|
45
|
+
throw new AiRequestInvalidError({
|
|
46
|
+
detail: `inProcessTool() was given the name ${JSON.stringify(def.name)}; a tool name is 1 to 64 letters, digits, "_" or "-", and "${RESPOND}" is the answer tool's`,
|
|
47
|
+
fix: "inProcessTool({ name: 'kbRead', … }) # letters, digits, _ and - only",
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
if (def.description.trim() === '') {
|
|
51
|
+
throw new AiRequestInvalidError({
|
|
52
|
+
detail: `inProcessTool("${def.name}") has an empty description, and the description is all the model has to decide when to call it`,
|
|
53
|
+
fix: `inProcessTool({ name: '${def.name}', description: 'Read one document by id.', … })`,
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
return {
|
|
57
|
+
name: def.name,
|
|
58
|
+
description: def.description,
|
|
59
|
+
// A schema the wire cannot express throws HERE, at declaration — as `respondFor`'s does.
|
|
60
|
+
inputJsonSchema: toWireSchema(def.input),
|
|
61
|
+
inProcess: true,
|
|
62
|
+
...(def.discloseErrors === undefined ? {} : { discloseErrors: def.discloseErrors }),
|
|
63
|
+
async run({ input, actor }) {
|
|
64
|
+
const parsed = await validateAsync(def.input, input);
|
|
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 });
|
|
75
|
+
},
|
|
76
|
+
};
|
|
77
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -5,14 +5,37 @@
|
|
|
5
5
|
export type { Infer } from '@ultimat3/schema';
|
|
6
6
|
export { t } from '@ultimat3/schema';
|
|
7
7
|
export { agent } from './agent';
|
|
8
|
+
export type {
|
|
9
|
+
AgentAction,
|
|
10
|
+
AgentContinuation,
|
|
11
|
+
AgentRun,
|
|
12
|
+
AgentTranscript,
|
|
13
|
+
} from './agent-continue';
|
|
14
|
+
export {
|
|
15
|
+
MAX_TRANSCRIPT_CHARS,
|
|
16
|
+
TRANSCRIPT_META,
|
|
17
|
+
TRANSCRIPT_VERSION,
|
|
18
|
+
transcriptOf,
|
|
19
|
+
} from './agent-continue';
|
|
8
20
|
export type { AgentBudget, AgentDef, AgentTurn, AgentVarsArgs } from './agent-def';
|
|
9
|
-
export {
|
|
21
|
+
export {
|
|
22
|
+
AgentDeadlineError,
|
|
23
|
+
AgentToolLoopError,
|
|
24
|
+
AgentTranscriptInvalidError,
|
|
25
|
+
} from './agent-errors';
|
|
10
26
|
export type { AgentBudgetFact, AgentFact } from './agent-facts';
|
|
11
27
|
export { describeAgents, resetAgents } from './agent-facts';
|
|
12
28
|
export type { AgentJobOptions } from './agent-job';
|
|
13
29
|
export { agentJob } from './agent-job';
|
|
14
30
|
export type { AgentSystem } from './agent-system';
|
|
15
31
|
export type { AgentRunUsage } from './agent-usage';
|
|
32
|
+
export {
|
|
33
|
+
AiProviderUnexpectedError,
|
|
34
|
+
LlmToolArgumentsError,
|
|
35
|
+
LlmTruncatedError,
|
|
36
|
+
} from './answer-errors';
|
|
37
|
+
export type { Billed } from './billed';
|
|
38
|
+
export { BILLED_META, billedOf } from './billed';
|
|
16
39
|
export type {
|
|
17
40
|
BudgetKeys,
|
|
18
41
|
BudgetLedger,
|
|
@@ -48,6 +71,7 @@ export {
|
|
|
48
71
|
} from './content-blocks';
|
|
49
72
|
export type { ContentRefusal } from './content-errors';
|
|
50
73
|
export { AiContentUnsupportedError } from './content-errors';
|
|
74
|
+
export { COST_SCALE_DIGITS, costScale, exactCostOf, roundUpCost } from './cost';
|
|
51
75
|
export type { EchoProvider, EchoProviderInput } from './echo-provider';
|
|
52
76
|
export { echoProvider } from './echo-provider';
|
|
53
77
|
export type { Embedder, HashEmbedder, HashEmbedderInput } from './embeddings';
|
|
@@ -79,7 +103,6 @@ export {
|
|
|
79
103
|
LlmOutputInvalidError,
|
|
80
104
|
LlmRefusedError,
|
|
81
105
|
LlmStreamInvalidError,
|
|
82
|
-
LlmTruncatedError,
|
|
83
106
|
VectorDimMismatchError,
|
|
84
107
|
VectorScopeWidenedError,
|
|
85
108
|
} from './errors';
|
|
@@ -129,7 +152,9 @@ export type { HiveDef, HiveSplitArgs } from './hive';
|
|
|
129
152
|
export { hive } from './hive';
|
|
130
153
|
export { HiveEmptyError } from './hive-errors';
|
|
131
154
|
export type { HiveMember, HiveMemberError, HiveOutput, HiveResult } from './hive-result';
|
|
132
|
-
export type {
|
|
155
|
+
export type { InProcessToolDef } from './in-process-tool';
|
|
156
|
+
export { inProcessTool } from './in-process-tool';
|
|
157
|
+
export type { LlmAction, LlmBudget, LlmCall, LlmDef, LlmVarsArgs } from './llm';
|
|
133
158
|
export { llm } from './llm';
|
|
134
159
|
export type { LlmCache, LlmScopeArgs, LlmSemanticCache } from './llm-cache';
|
|
135
160
|
export type { LlmStreamChunk } from './llm-stream';
|
|
@@ -248,6 +273,7 @@ export {
|
|
|
248
273
|
configureAi,
|
|
249
274
|
semanticCacheFor,
|
|
250
275
|
} from './runtime';
|
|
276
|
+
export type { Sampling } from './sampling';
|
|
251
277
|
export type { Scorer } from './scorers';
|
|
252
278
|
export {
|
|
253
279
|
contains,
|
package/src/llm-def.ts
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
// Single responsibility: what an `llm()` DECLARATION is — its options, and the one event its
|
|
2
|
+
// observer is handed. Types only; ./llm owns the call that reads them. `agent-def.ts`'s twin.
|
|
3
|
+
|
|
4
|
+
import type { ActionMcp, ActionPolicy } from '@ultimat3/action';
|
|
5
|
+
import type { Ctx } from '@ultimat3/core';
|
|
6
|
+
import type { Money } from '@ultimat3/money';
|
|
7
|
+
import type { InferOutput, StandardSchemaV1 } from '@ultimat3/schema';
|
|
8
|
+
import type { AgentRunUsage } from './agent-usage';
|
|
9
|
+
import type { LlmCache } from './llm-cache';
|
|
10
|
+
import type { ModelId } from './models';
|
|
11
|
+
import type { Prompt, PromptVars } from './prompt';
|
|
12
|
+
import type { PromptCacheOption } from './prompt-cache';
|
|
13
|
+
import type { StopReason, TokenUsage } from './provider';
|
|
14
|
+
|
|
15
|
+
/** Per-call ceilings, checked before the provider is reached. Never truncates — refuses. */
|
|
16
|
+
export interface LlmBudget {
|
|
17
|
+
/** Prompt tokens. */
|
|
18
|
+
readonly tokensIn?: number;
|
|
19
|
+
/** Worst-case price of one call, integer minor units. */
|
|
20
|
+
readonly costPerCall?: Money;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface LlmVarsArgs<TInput extends StandardSchemaV1> {
|
|
24
|
+
readonly input: InferOutput<TInput>;
|
|
25
|
+
readonly ctx: Ctx;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface LlmDef<
|
|
29
|
+
TInput extends StandardSchemaV1,
|
|
30
|
+
TOutput extends StandardSchemaV1,
|
|
31
|
+
V extends PromptVars,
|
|
32
|
+
> {
|
|
33
|
+
/**
|
|
34
|
+
* Overrides the prompt artifact's own model. The prompt's is part of its content hash and
|
|
35
|
+
* travels with it; this one is the deployment decision, so it wins.
|
|
36
|
+
*/
|
|
37
|
+
readonly model?: ModelId;
|
|
38
|
+
readonly input: TInput;
|
|
39
|
+
readonly output: TOutput;
|
|
40
|
+
readonly prompt: Prompt<V>;
|
|
41
|
+
/**
|
|
42
|
+
* Everything the prompt is allowed to see, derived from the parsed input. Required, and
|
|
43
|
+
* async, because the input is usually an id and the prompt needs the row behind it — this
|
|
44
|
+
* is the one declared place a model call loads data, so a reader can see what was sent.
|
|
45
|
+
*/
|
|
46
|
+
vars(args: LlmVarsArgs<TInput>): V | Promise<V>;
|
|
47
|
+
readonly cache?: LlmCache<InferOutput<TInput>>;
|
|
48
|
+
/**
|
|
49
|
+
* PROVIDER prompt caching (`./prompt-cache`) — not `cache`, which is this package's own semantic
|
|
50
|
+
* cache. Default `'off'`: a one-shot call marked for caching pays the write premium (1.25x on
|
|
51
|
+
* Anthropic) for a prefix nothing may ever read back. `'auto'` is for a call made many times
|
|
52
|
+
* inside the cache's lifetime with one long `system`; the repair turn reads it back either way.
|
|
53
|
+
*/
|
|
54
|
+
readonly promptCache?: PromptCacheOption<LlmVarsArgs<TInput>>;
|
|
55
|
+
readonly budget?: LlmBudget;
|
|
56
|
+
readonly policy: ActionPolicy;
|
|
57
|
+
readonly mcp?: ActionMcp;
|
|
58
|
+
/** Enforced completion ceiling. The model never sees it, so it can be cut off mid-answer. */
|
|
59
|
+
readonly maxTokens?: number;
|
|
60
|
+
/**
|
|
61
|
+
* `temperature` (0 to 2) and `seed` (a whole number), for a call that should answer the same way
|
|
62
|
+
* twice — a verifier, a classifier. Sent only to a model whose row takes them
|
|
63
|
+
* (`registerModel({ sampling })`); to any other they are NOT sent, because a reasoning model
|
|
64
|
+
* answers one with a 400 — the `llm.sampling.ignored` span attribute names what was left out.
|
|
65
|
+
* `seed` exists on the chat-completions wire only, and is best effort where it exists.
|
|
66
|
+
*/
|
|
67
|
+
readonly temperature?: number;
|
|
68
|
+
readonly seed?: number;
|
|
69
|
+
/**
|
|
70
|
+
* Called once per PROVIDER call this invocation makes — the answer, and the repair turn when
|
|
71
|
+
* there is one — before the answer is read. What `agent()`'s `onTurn` is to a run: the usage and
|
|
72
|
+
* cost of each call, and on a repair call the issues the model was told to fix.
|
|
73
|
+
*
|
|
74
|
+
* Observation only. Awaited, and a throw from it FAILS the call, for the reason `onTurn` gives.
|
|
75
|
+
* Not called on a semantic-cache hit: no provider call was made, and nothing was billed.
|
|
76
|
+
*/
|
|
77
|
+
onCall?(event: LlmCall): void | Promise<void>;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** One completed provider call of an `llm()` invocation, as an observer sees it. Facts only. */
|
|
81
|
+
export interface LlmCall {
|
|
82
|
+
/** 1 is the answer; 2 is the repair turn. A streamed call is always 1. */
|
|
83
|
+
readonly attempt: number;
|
|
84
|
+
readonly model: ModelId;
|
|
85
|
+
/** Which provider answered, as the gateway stamped it. */
|
|
86
|
+
readonly provider: string | undefined;
|
|
87
|
+
readonly stopReason: StopReason;
|
|
88
|
+
readonly usage: TokenUsage;
|
|
89
|
+
/** This call's cost alone, integer minor units, rounded up. */
|
|
90
|
+
readonly cost: Money;
|
|
91
|
+
/** This call's cost exactly (`Money` at a finer `scale`); absent when the provider stated none. */
|
|
92
|
+
readonly costExact?: Money;
|
|
93
|
+
/** On the repair turn: the schema issues of the first answer — what the model was told to fix. */
|
|
94
|
+
readonly repairOf?: readonly string[];
|
|
95
|
+
/** The invocation so far, this call included — priced once from the summed usage. */
|
|
96
|
+
readonly run: AgentRunUsage;
|
|
97
|
+
}
|
package/src/llm.ts
CHANGED
|
@@ -21,38 +21,36 @@
|
|
|
21
21
|
* same rules.
|
|
22
22
|
*/
|
|
23
23
|
|
|
24
|
-
import type { Action,
|
|
24
|
+
import type { Action, InvokeOptions } from '@ultimat3/action';
|
|
25
25
|
import { action } from '@ultimat3/action';
|
|
26
26
|
import type { Ctx, Span, SpanAttributes } from '@ultimat3/core';
|
|
27
27
|
import { finiteCount, withSpan } from '@ultimat3/core';
|
|
28
|
-
import type { Money } from '@ultimat3/money';
|
|
29
28
|
import type { InferInput, InferOutput, StandardSchemaV1 } from '@ultimat3/schema';
|
|
30
29
|
import { formatIssues, validateAsync } from '@ultimat3/schema';
|
|
30
|
+
import { runTotal } from './agent-usage';
|
|
31
|
+
import { LlmTruncatedError } from './answer-errors';
|
|
31
32
|
import type { BudgetLimits } from './budget';
|
|
32
33
|
import { budgetKeysFor, currentBudget, screenedMoney, withBudget } from './budget';
|
|
33
|
-
import {
|
|
34
|
-
LlmOutputInvalidError,
|
|
35
|
-
LlmRefusedError,
|
|
36
|
-
LlmStreamInvalidError,
|
|
37
|
-
LlmTruncatedError,
|
|
38
|
-
} from './errors';
|
|
34
|
+
import { LlmOutputInvalidError, LlmRefusedError, LlmStreamInvalidError } from './errors';
|
|
39
35
|
import type { Gateway } from './gateway';
|
|
40
|
-
import type { LlmCache } from './llm-cache';
|
|
41
36
|
import { openCache } from './llm-cache';
|
|
37
|
+
import type { LlmBudget, LlmDef } from './llm-def';
|
|
42
38
|
import type { LlmSink, LlmStreamChunk } from './llm-stream';
|
|
43
39
|
import { currentLlmSink, llmStream, streamOneTurn, withLlmSink } from './llm-stream';
|
|
44
40
|
import { modelForCall } from './model-resolve';
|
|
45
|
-
import type { ModelId } from './models';
|
|
46
41
|
import { moreCapableThan } from './models';
|
|
47
|
-
import type {
|
|
48
|
-
import type { PromptCacheOption } from './prompt-cache';
|
|
42
|
+
import type { PromptVars } from './prompt';
|
|
49
43
|
import { promptCacheHint } from './prompt-cache';
|
|
50
44
|
import type { AiMessage, GenerateRequest, GenerateResult } from './provider';
|
|
51
45
|
import { isTruncated } from './provider';
|
|
52
46
|
import { assertNoSecrets } from './redaction';
|
|
53
47
|
import type { Respond } from './respond';
|
|
54
48
|
import { parseJsonish, RESPOND, respondFor } from './respond';
|
|
55
|
-
import {
|
|
49
|
+
import type { RunMeter } from './run-meter';
|
|
50
|
+
import { runMeter } from './run-meter';
|
|
51
|
+
import { aiGateway, aiRedactor, installedGateway } from './runtime';
|
|
52
|
+
import type { Sampling } from './sampling';
|
|
53
|
+
import { ignoredSampling, screenSampling } from './sampling';
|
|
56
54
|
|
|
57
55
|
/** Two attempts total: the answer, then one repair turn. See `LlmOutputInvalidError`. */
|
|
58
56
|
const ATTEMPTS = 2;
|
|
@@ -63,52 +61,7 @@ const ATTEMPTS = 2;
|
|
|
63
61
|
*/
|
|
64
62
|
const DEFAULT_MAX_TOKENS = 4_096;
|
|
65
63
|
|
|
66
|
-
|
|
67
|
-
export interface LlmBudget {
|
|
68
|
-
/** Prompt tokens. */
|
|
69
|
-
readonly tokensIn?: number;
|
|
70
|
-
/** Worst-case price of one call, integer minor units. */
|
|
71
|
-
readonly costPerCall?: Money;
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
export interface LlmVarsArgs<TInput extends StandardSchemaV1> {
|
|
75
|
-
readonly input: InferOutput<TInput>;
|
|
76
|
-
readonly ctx: Ctx;
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
export interface LlmDef<
|
|
80
|
-
TInput extends StandardSchemaV1,
|
|
81
|
-
TOutput extends StandardSchemaV1,
|
|
82
|
-
V extends PromptVars,
|
|
83
|
-
> {
|
|
84
|
-
/**
|
|
85
|
-
* Overrides the prompt artifact's own model. The prompt's is part of its content hash and
|
|
86
|
-
* travels with it; this one is the deployment decision, so it wins.
|
|
87
|
-
*/
|
|
88
|
-
readonly model?: ModelId;
|
|
89
|
-
readonly input: TInput;
|
|
90
|
-
readonly output: TOutput;
|
|
91
|
-
readonly prompt: Prompt<V>;
|
|
92
|
-
/**
|
|
93
|
-
* Everything the prompt is allowed to see, derived from the parsed input. Required, and
|
|
94
|
-
* async, because the input is usually an id and the prompt needs the row behind it — this
|
|
95
|
-
* is the one declared place a model call loads data, so a reader can see what was sent.
|
|
96
|
-
*/
|
|
97
|
-
vars(args: LlmVarsArgs<TInput>): V | Promise<V>;
|
|
98
|
-
readonly cache?: LlmCache<InferOutput<TInput>>;
|
|
99
|
-
/**
|
|
100
|
-
* PROVIDER prompt caching (`./prompt-cache`) — not `cache`, which is this package's own semantic
|
|
101
|
-
* cache. Default `'off'`: a one-shot call marked for caching pays the write premium (1.25x on
|
|
102
|
-
* Anthropic) for a prefix nothing may ever read back. `'auto'` is for a call made many times
|
|
103
|
-
* inside the cache's lifetime with one long `system`; the repair turn reads it back either way.
|
|
104
|
-
*/
|
|
105
|
-
readonly promptCache?: PromptCacheOption<LlmVarsArgs<TInput>>;
|
|
106
|
-
readonly budget?: LlmBudget;
|
|
107
|
-
readonly policy: ActionPolicy;
|
|
108
|
-
readonly mcp?: ActionMcp;
|
|
109
|
-
/** Enforced completion ceiling. The model never sees it, so it can be cut off mid-answer. */
|
|
110
|
-
readonly maxTokens?: number;
|
|
111
|
-
}
|
|
64
|
+
export type { LlmBudget, LlmCall, LlmDef, LlmVarsArgs } from './llm-def';
|
|
112
65
|
|
|
113
66
|
/**
|
|
114
67
|
* An `action` with one extra way to be called. Every projection an action has, it has — its MCP tool,
|
|
@@ -152,12 +105,13 @@ export function llm<
|
|
|
152
105
|
// off for the life of the process. A floor of 1 because a completion ceiling of zero tokens is
|
|
153
106
|
// a call that cannot answer.
|
|
154
107
|
finiteCount('llm', 'maxTokens', def.maxTokens ?? DEFAULT_MAX_TOKENS, 1);
|
|
108
|
+
const sampling = screenSampling('llm', def);
|
|
155
109
|
const built = action<TInput, TOutput>({
|
|
156
110
|
input: def.input,
|
|
157
111
|
output: def.output,
|
|
158
112
|
policy: def.policy,
|
|
159
113
|
...(def.mcp === undefined ? {} : { mcp: def.mcp }),
|
|
160
|
-
handle: (args) => generate(def, respond, args),
|
|
114
|
+
handle: (args) => generate(def, respond, sampling, args),
|
|
161
115
|
});
|
|
162
116
|
return streamable(built);
|
|
163
117
|
}
|
|
@@ -195,6 +149,7 @@ async function generate<
|
|
|
195
149
|
>(
|
|
196
150
|
def: LlmDef<TInput, TOutput, V>,
|
|
197
151
|
respond: Respond,
|
|
152
|
+
sampling: Sampling,
|
|
198
153
|
args: { readonly input: InferOutput<TInput>; readonly ctx: Ctx },
|
|
199
154
|
): Promise<InferOutput<TOutput>> {
|
|
200
155
|
const { prompt } = def;
|
|
@@ -227,6 +182,11 @@ async function generate<
|
|
|
227
182
|
// an audit asks evidence for, and a redactor that silently stopped matching looks identical
|
|
228
183
|
// to one that had nothing to remove until this attribute separates them.
|
|
229
184
|
'llm.redacted': redacted,
|
|
185
|
+
'llm.sampling.ignored': ignoredSampling(
|
|
186
|
+
model,
|
|
187
|
+
sampling,
|
|
188
|
+
installedGateway()?.samplingWires?.(model),
|
|
189
|
+
).join(','),
|
|
230
190
|
});
|
|
231
191
|
|
|
232
192
|
// A cached answer is still data of unknown provenance, so it goes through the schema like
|
|
@@ -254,6 +214,7 @@ async function generate<
|
|
|
254
214
|
maxTokens: def.maxTokens ?? DEFAULT_MAX_TOKENS,
|
|
255
215
|
...(prompt.effort === undefined ? {} : { effort: prompt.effort }),
|
|
256
216
|
...(prompt.thinking === undefined ? {} : { thinking: prompt.thinking }),
|
|
217
|
+
...sampling,
|
|
257
218
|
// The caller's own signal, forwarded to the transport exactly as `agent()` does — and
|
|
258
219
|
// inherited by `streamedAnswer` from this same `base`. Without it a disconnected caller left
|
|
259
220
|
// the provider call in flight, billed and unread, and the repair turn bought a SECOND one.
|
|
@@ -272,17 +233,22 @@ async function generate<
|
|
|
272
233
|
);
|
|
273
234
|
const sink = currentLlmSink();
|
|
274
235
|
|
|
275
|
-
|
|
236
|
+
// Every provider call goes through the meter, so a failure AFTER one answered — a refusal, a
|
|
237
|
+
// truncation, a schema no repair satisfied — is thrown carrying what it was billed.
|
|
238
|
+
const meter = runMeter();
|
|
239
|
+
const answer = async (): Promise<InferOutput<TOutput>> => {
|
|
276
240
|
if (sink !== undefined) {
|
|
277
|
-
const value = await streamedAnswer(def
|
|
241
|
+
const value = await streamedAnswer(def, name, gateway, base, sink, span, meter);
|
|
278
242
|
await cache?.remember(value);
|
|
279
243
|
return value;
|
|
280
244
|
}
|
|
281
245
|
let messages: readonly AiMessage[] = request.messages;
|
|
282
246
|
let issues = 'no output';
|
|
247
|
+
let rejected: readonly string[] | undefined;
|
|
283
248
|
for (let attempt = 1; attempt <= ATTEMPTS; attempt += 1) {
|
|
284
|
-
const result = await gateway.generate({ ...request, messages });
|
|
249
|
+
const result = await meter.call(gateway.generate({ ...request, messages }));
|
|
285
250
|
span.setAttributes({ 'llm.attempts': attempt, ...answerAttributes(result) });
|
|
251
|
+
await observeCall(def, meter, result, attempt, rejected);
|
|
286
252
|
// Branch on the stop reason BEFORE reading the answer. A refusal carries empty or partial
|
|
287
253
|
// content, so parsing it first reports a schema disagreement — a cause that is wrong, a
|
|
288
254
|
// fix that does not apply, and a repair turn spent buying the same refusal again.
|
|
@@ -298,25 +264,38 @@ async function generate<
|
|
|
298
264
|
explanation: result.stopDetails?.explanation,
|
|
299
265
|
});
|
|
300
266
|
}
|
|
267
|
+
// Cut off, so there is no answer to read — and it is read BEFORE the schema for the reason
|
|
268
|
+
// the refusal is: a `respond` call stopped at the ceiling arrives with no arguments, both
|
|
269
|
+
// wires read that as `{}`, and `{}` SATISFIES an `output` whose keys are optional. The
|
|
270
|
+
// ceiling is the same on the next attempt, so the repair turn is a second truncation.
|
|
271
|
+
if (isTruncated(result.stopReason)) {
|
|
272
|
+
throw new LlmTruncatedError({ prompt: name, maxTokens: request.maxTokens });
|
|
273
|
+
}
|
|
301
274
|
const parsed = await validateAsync(def.output, respond.read(result));
|
|
302
275
|
if (parsed.issues === undefined) {
|
|
303
276
|
await cache?.remember(parsed.value);
|
|
304
277
|
return parsed.value;
|
|
305
278
|
}
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
if (isTruncated(result.stopReason)) {
|
|
309
|
-
throw new LlmTruncatedError({ prompt: name, maxTokens: request.maxTokens });
|
|
310
|
-
}
|
|
311
|
-
issues = formatIssues(parsed.issues).join('; ');
|
|
279
|
+
rejected = formatIssues(parsed.issues);
|
|
280
|
+
issues = rejected.join('; ');
|
|
312
281
|
const echo = assistantEcho(result);
|
|
313
282
|
messages =
|
|
314
283
|
echo === undefined
|
|
315
284
|
? [...messages, repair(issues)]
|
|
316
285
|
: [...messages, { role: 'assistant', content: echo }, repair(issues)];
|
|
317
286
|
}
|
|
318
|
-
throw new LlmOutputInvalidError({
|
|
319
|
-
|
|
287
|
+
throw new LlmOutputInvalidError({
|
|
288
|
+
prompt: name,
|
|
289
|
+
attempts: ATTEMPTS,
|
|
290
|
+
issues,
|
|
291
|
+
...(rejected === undefined ? {} : { list: rejected }),
|
|
292
|
+
});
|
|
293
|
+
};
|
|
294
|
+
return withBudget(ledger, () =>
|
|
295
|
+
answer().catch((error: unknown) => {
|
|
296
|
+
throw meter.fail(error);
|
|
297
|
+
}),
|
|
298
|
+
);
|
|
320
299
|
});
|
|
321
300
|
}
|
|
322
301
|
|
|
@@ -345,16 +324,23 @@ export function answerAttributes(result: GenerateResult): SpanAttributes {
|
|
|
345
324
|
* carries empty or partial content and parsing it first reports a schema disagreement that is not
|
|
346
325
|
* one. Truncation is the same call it is on the non-streaming path.
|
|
347
326
|
*/
|
|
348
|
-
async function streamedAnswer<
|
|
349
|
-
|
|
327
|
+
async function streamedAnswer<
|
|
328
|
+
TInput extends StandardSchemaV1,
|
|
329
|
+
TOutput extends StandardSchemaV1,
|
|
330
|
+
V extends PromptVars,
|
|
331
|
+
>(
|
|
332
|
+
def: LlmDef<TInput, TOutput, V>,
|
|
350
333
|
name: string,
|
|
351
334
|
gateway: Gateway,
|
|
352
335
|
request: GenerateRequest,
|
|
353
336
|
sink: LlmSink,
|
|
354
337
|
span: Span,
|
|
338
|
+
meter: RunMeter,
|
|
355
339
|
): Promise<InferOutput<TOutput>> {
|
|
356
|
-
const
|
|
340
|
+
const { output } = def;
|
|
341
|
+
const result = await meter.call(streamOneTurn(gateway, request, sink));
|
|
357
342
|
span.setAttributes({ 'llm.attempts': 1, ...answerAttributes(result) });
|
|
343
|
+
await observeCall(def, meter, result, 1, undefined);
|
|
358
344
|
if (result.stopReason === 'refusal') {
|
|
359
345
|
throw new LlmRefusedError({
|
|
360
346
|
prompt: name,
|
|
@@ -380,6 +366,32 @@ async function streamedAnswer<TOutput extends StandardSchemaV1>(
|
|
|
380
366
|
});
|
|
381
367
|
}
|
|
382
368
|
|
|
369
|
+
/** One provider call, reported to the declaration's `onCall`. Awaited; a throw fails the call. */
|
|
370
|
+
async function observeCall<
|
|
371
|
+
TInput extends StandardSchemaV1,
|
|
372
|
+
TOutput extends StandardSchemaV1,
|
|
373
|
+
V extends PromptVars,
|
|
374
|
+
>(
|
|
375
|
+
def: LlmDef<TInput, TOutput, V>,
|
|
376
|
+
meter: RunMeter,
|
|
377
|
+
result: GenerateResult,
|
|
378
|
+
attempt: number,
|
|
379
|
+
repairOf: readonly string[] | undefined,
|
|
380
|
+
): Promise<void> {
|
|
381
|
+
if (def.onCall === undefined) return;
|
|
382
|
+
await def.onCall({
|
|
383
|
+
attempt,
|
|
384
|
+
model: result.model,
|
|
385
|
+
provider: result.provider,
|
|
386
|
+
stopReason: result.stopReason,
|
|
387
|
+
usage: result.usage,
|
|
388
|
+
cost: result.cost,
|
|
389
|
+
...(result.costExact === undefined ? {} : { costExact: result.costExact }),
|
|
390
|
+
...(repairOf === undefined ? {} : { repairOf }),
|
|
391
|
+
run: runTotal(result.model, undefined, meter.total()),
|
|
392
|
+
});
|
|
393
|
+
}
|
|
394
|
+
|
|
383
395
|
/** `undefined` for "does not fit". Wrapped so a legitimately falsy value is still a hit. */
|
|
384
396
|
async function accept<TOutput extends StandardSchemaV1>(
|
|
385
397
|
schema: TOutput,
|