@ultimat3/ai 27.9.0 → 27.10.1

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/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 type { BudgetKeys, BudgetLimits, BudgetStore } from './budget';
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
- await ledger?.release(reservation);
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
- await ledger?.record(result.usage, result.cost, reservation);
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
- await ledger?.record(chunk.result.usage, chunk.result.cost, reservation);
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 that threw, or that its consumer abandoned, never reached `done` — so nothing
249
- // reconciled the reservation and it would hold the ceiling for the rest of the window.
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 { AgentDeadlineError, AgentToolLoopError } from './agent-errors';
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 { LlmAction, LlmBudget, LlmDef, LlmVarsArgs } from './llm';
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, ActionMcp, ActionPolicy, InvokeOptions } from '@ultimat3/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 { Prompt, PromptVars } from './prompt';
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 { aiGateway, aiRedactor } from './runtime';
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 { ignoredSamplingAttribute, 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
- /** Per-call ceilings, checked before the provider is reached. Never truncates — refuses. */
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;
@@ -228,6 +183,12 @@ async function generate<
228
183
  // to one that had nothing to remove until this attribute separates them.
229
184
  'llm.redacted': redacted,
230
185
  });
186
+ // Read when the call ends, not here: the gateway may be what registers the model's row.
187
+ const reportSampling = (): void => {
188
+ span.setAttributes(
189
+ ignoredSamplingAttribute(model, sampling, () => installedGateway()?.samplingWires?.(model)),
190
+ );
191
+ };
231
192
 
232
193
  // A cached answer is still data of unknown provenance, so it goes through the schema like
233
194
  // any other. One that no longer fits — the schema moved under it — is a miss, not a
@@ -241,7 +202,10 @@ async function generate<
241
202
  });
242
203
  const hit = await accept(def.output, await cache?.lookup());
243
204
  span.setAttribute('llm.cache.hit', hit !== undefined);
244
- if (hit !== undefined) return hit.value;
205
+ if (hit !== undefined) {
206
+ reportSampling();
207
+ return hit.value;
208
+ }
245
209
 
246
210
  // No `tools` yet: the `respond` projection belongs to the non-streaming path alone. A tool
247
211
  // call is emitted whole, so forcing the answer through one leaves a stream with nothing to
@@ -254,6 +218,7 @@ async function generate<
254
218
  maxTokens: def.maxTokens ?? DEFAULT_MAX_TOKENS,
255
219
  ...(prompt.effort === undefined ? {} : { effort: prompt.effort }),
256
220
  ...(prompt.thinking === undefined ? {} : { thinking: prompt.thinking }),
221
+ ...sampling,
257
222
  // The caller's own signal, forwarded to the transport exactly as `agent()` does — and
258
223
  // inherited by `streamedAnswer` from this same `base`. Without it a disconnected caller left
259
224
  // the provider call in flight, billed and unread, and the repair turn bought a SECOND one.
@@ -272,17 +237,22 @@ async function generate<
272
237
  );
273
238
  const sink = currentLlmSink();
274
239
 
275
- return withBudget(ledger, async () => {
240
+ // Every provider call goes through the meter, so a failure AFTER one answered — a refusal, a
241
+ // truncation, a schema no repair satisfied — is thrown carrying what it was billed.
242
+ const meter = runMeter();
243
+ const answer = async (): Promise<InferOutput<TOutput>> => {
276
244
  if (sink !== undefined) {
277
- const value = await streamedAnswer(def.output, name, gateway, base, sink, span);
245
+ const value = await streamedAnswer(def, name, gateway, base, sink, span, meter);
278
246
  await cache?.remember(value);
279
247
  return value;
280
248
  }
281
249
  let messages: readonly AiMessage[] = request.messages;
282
250
  let issues = 'no output';
251
+ let rejected: readonly string[] | undefined;
283
252
  for (let attempt = 1; attempt <= ATTEMPTS; attempt += 1) {
284
- const result = await gateway.generate({ ...request, messages });
253
+ const result = await meter.call(gateway.generate({ ...request, messages }));
285
254
  span.setAttributes({ 'llm.attempts': attempt, ...answerAttributes(result) });
255
+ await observeCall(def, meter, result, attempt, rejected);
286
256
  // Branch on the stop reason BEFORE reading the answer. A refusal carries empty or partial
287
257
  // content, so parsing it first reports a schema disagreement — a cause that is wrong, a
288
258
  // fix that does not apply, and a repair turn spent buying the same refusal again.
@@ -298,25 +268,40 @@ async function generate<
298
268
  explanation: result.stopDetails?.explanation,
299
269
  });
300
270
  }
271
+ // Cut off, so there is no answer to read — and it is read BEFORE the schema for the reason
272
+ // the refusal is: a `respond` call stopped at the ceiling arrives with no arguments, both
273
+ // wires read that as `{}`, and `{}` SATISFIES an `output` whose keys are optional. The
274
+ // ceiling is the same on the next attempt, so the repair turn is a second truncation.
275
+ if (isTruncated(result.stopReason)) {
276
+ throw new LlmTruncatedError({ prompt: name, maxTokens: request.maxTokens });
277
+ }
301
278
  const parsed = await validateAsync(def.output, respond.read(result));
302
279
  if (parsed.issues === undefined) {
303
280
  await cache?.remember(parsed.value);
304
281
  return parsed.value;
305
282
  }
306
- // A cut-off answer that also fails its schema is not a disagreement about the shape: the
307
- // ceiling is the same on the next attempt, so the repair turn is a second truncation.
308
- if (isTruncated(result.stopReason)) {
309
- throw new LlmTruncatedError({ prompt: name, maxTokens: request.maxTokens });
310
- }
311
- issues = formatIssues(parsed.issues).join('; ');
283
+ rejected = formatIssues(parsed.issues);
284
+ issues = rejected.join('; ');
312
285
  const echo = assistantEcho(result);
313
286
  messages =
314
287
  echo === undefined
315
288
  ? [...messages, repair(issues)]
316
289
  : [...messages, { role: 'assistant', content: echo }, repair(issues)];
317
290
  }
318
- throw new LlmOutputInvalidError({ prompt: name, attempts: ATTEMPTS, issues });
319
- });
291
+ throw new LlmOutputInvalidError({
292
+ prompt: name,
293
+ attempts: ATTEMPTS,
294
+ issues,
295
+ ...(rejected === undefined ? {} : { list: rejected }),
296
+ });
297
+ };
298
+ return withBudget(ledger, () =>
299
+ answer()
300
+ .catch((error: unknown) => {
301
+ throw meter.fail(error);
302
+ })
303
+ .finally(reportSampling),
304
+ );
320
305
  });
321
306
  }
322
307
 
@@ -345,16 +330,23 @@ export function answerAttributes(result: GenerateResult): SpanAttributes {
345
330
  * carries empty or partial content and parsing it first reports a schema disagreement that is not
346
331
  * one. Truncation is the same call it is on the non-streaming path.
347
332
  */
348
- async function streamedAnswer<TOutput extends StandardSchemaV1>(
349
- output: TOutput,
333
+ async function streamedAnswer<
334
+ TInput extends StandardSchemaV1,
335
+ TOutput extends StandardSchemaV1,
336
+ V extends PromptVars,
337
+ >(
338
+ def: LlmDef<TInput, TOutput, V>,
350
339
  name: string,
351
340
  gateway: Gateway,
352
341
  request: GenerateRequest,
353
342
  sink: LlmSink,
354
343
  span: Span,
344
+ meter: RunMeter,
355
345
  ): Promise<InferOutput<TOutput>> {
356
- const result = await streamOneTurn(gateway, request, sink);
346
+ const { output } = def;
347
+ const result = await meter.call(streamOneTurn(gateway, request, sink));
357
348
  span.setAttributes({ 'llm.attempts': 1, ...answerAttributes(result) });
349
+ await observeCall(def, meter, result, 1, undefined);
358
350
  if (result.stopReason === 'refusal') {
359
351
  throw new LlmRefusedError({
360
352
  prompt: name,
@@ -380,6 +372,32 @@ async function streamedAnswer<TOutput extends StandardSchemaV1>(
380
372
  });
381
373
  }
382
374
 
375
+ /** One provider call, reported to the declaration's `onCall`. Awaited; a throw fails the call. */
376
+ async function observeCall<
377
+ TInput extends StandardSchemaV1,
378
+ TOutput extends StandardSchemaV1,
379
+ V extends PromptVars,
380
+ >(
381
+ def: LlmDef<TInput, TOutput, V>,
382
+ meter: RunMeter,
383
+ result: GenerateResult,
384
+ attempt: number,
385
+ repairOf: readonly string[] | undefined,
386
+ ): Promise<void> {
387
+ if (def.onCall === undefined) return;
388
+ await def.onCall({
389
+ attempt,
390
+ model: result.model,
391
+ provider: result.provider,
392
+ stopReason: result.stopReason,
393
+ usage: result.usage,
394
+ cost: result.cost,
395
+ ...(result.costExact === undefined ? {} : { costExact: result.costExact }),
396
+ ...(repairOf === undefined ? {} : { repairOf }),
397
+ run: runTotal(result.model, undefined, meter.total()),
398
+ });
399
+ }
400
+
383
401
  /** `undefined` for "does not fit". Wrapped so a legitimately falsy value is still a hit. */
384
402
  async function accept<TOutput extends StandardSchemaV1>(
385
403
  schema: TOutput,