@ultimat3/ai 27.8.1 → 27.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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
@@ -180,17 +184,29 @@ class GatewayImpl implements Gateway {
180
184
  // The estimate is DEBITED here, not merely checked: three concurrent calls under one ledger
181
185
  // all read the same `spent()` otherwise, all pass, and all three record against a ceiling
182
186
  // only one of them fitted.
183
- const reservation = await ledger?.reserve(estimateSpend(resolved));
187
+ const reservation = await ledger?.reserve(estimateSpend(resolved, this.marks(model)));
184
188
 
185
189
  let result: GenerateResult;
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);
@@ -230,7 +246,7 @@ class GatewayImpl implements Gateway {
230
246
  const provider = this.providerFor(model);
231
247
 
232
248
  const ledger = currentBudget() ?? this.callLedger({});
233
- const reservation = await ledger?.reserve(estimateSpend(resolved));
249
+ const reservation = await ledger?.reserve(estimateSpend(resolved, this.marks(model)));
234
250
  let settled = false;
235
251
  try {
236
252
  for await (const chunk of provider.stream(resolved)) {
@@ -240,17 +256,40 @@ 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
+
286
+ /** Whether any provider serving `model` would send a hinted request with a cache mark. */
287
+ private marks(model: ModelId): boolean {
288
+ return this.config.providers.some(
289
+ (p) => p.models.includes(model) && p.marksPromptCache?.(model) === true,
290
+ );
291
+ }
292
+
254
293
  private providerFor(model: ModelId): Provider {
255
294
  const provider = this.config.providers.find((p) => p.models.includes(model));
256
295
  if (provider === undefined) {
@@ -319,6 +358,17 @@ class GatewayImpl implements Gateway {
319
358
  }
320
359
  }
321
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
+
322
372
  /**
323
373
  * The request's completion ceiling, screened at the one seam every model call in an app passes
324
374
  * through — `llm()`, `agent()`, an eval, a judge and a hand-built `generate()` alike.
@@ -416,5 +466,8 @@ export function promptCacheKey(request: GenerateRequest): string {
416
466
  thinking: request.thinking ?? 'adaptive',
417
467
  tools: request.tools ?? [],
418
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 }),
419
472
  });
420
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
@@ -4,12 +4,38 @@
4
4
  /** Re-exported so an `llm` file needs one import, not two. Same object as schema's. */
5
5
  export type { Infer } from '@ultimat3/schema';
6
6
  export { t } from '@ultimat3/schema';
7
- export type { AgentBudget, AgentDef, AgentTurn, AgentVarsArgs } from './agent';
8
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';
20
+ export type { AgentBudget, AgentDef, AgentTurn, AgentVarsArgs } from './agent-def';
21
+ export {
22
+ AgentDeadlineError,
23
+ AgentToolLoopError,
24
+ AgentTranscriptInvalidError,
25
+ } from './agent-errors';
9
26
  export type { AgentBudgetFact, AgentFact } from './agent-facts';
10
27
  export { describeAgents, resetAgents } from './agent-facts';
11
28
  export type { AgentJobOptions } from './agent-job';
12
29
  export { agentJob } from './agent-job';
30
+ export type { AgentSystem } from './agent-system';
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';
13
39
  export type {
14
40
  BudgetKeys,
15
41
  BudgetLedger,
@@ -45,6 +71,7 @@ export {
45
71
  } from './content-blocks';
46
72
  export type { ContentRefusal } from './content-errors';
47
73
  export { AiContentUnsupportedError } from './content-errors';
74
+ export { COST_SCALE_DIGITS, costScale, exactCostOf, roundUpCost } from './cost';
48
75
  export type { EchoProvider, EchoProviderInput } from './echo-provider';
49
76
  export { echoProvider } from './echo-provider';
50
77
  export type { Embedder, HashEmbedder, HashEmbedderInput } from './embeddings';
@@ -76,7 +103,6 @@ export {
76
103
  LlmOutputInvalidError,
77
104
  LlmRefusedError,
78
105
  LlmStreamInvalidError,
79
- LlmTruncatedError,
80
106
  VectorDimMismatchError,
81
107
  VectorScopeWidenedError,
82
108
  } from './errors';
@@ -126,7 +152,9 @@ export type { HiveDef, HiveSplitArgs } from './hive';
126
152
  export { hive } from './hive';
127
153
  export { HiveEmptyError } from './hive-errors';
128
154
  export type { HiveMember, HiveMemberError, HiveOutput, HiveResult } from './hive-result';
129
- 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';
130
158
  export { llm } from './llm';
131
159
  export type { LlmCache, LlmScopeArgs, LlmSemanticCache } from './llm-cache';
132
160
  export type { LlmStreamChunk } from './llm-stream';
@@ -177,6 +205,21 @@ export {
177
205
  promptVersions,
178
206
  resetPrompts,
179
207
  } from './prompt';
208
+ export type {
209
+ CacheWire,
210
+ PromptCacheHint,
211
+ PromptCacheOption,
212
+ PromptCacheRule,
213
+ SystemParts,
214
+ SystemPrompt,
215
+ } from './prompt-cache';
216
+ export {
217
+ OPENAI_CACHE_KEY_MAX,
218
+ PROMPT_CACHE_RULES,
219
+ promptCacheRule,
220
+ SESSION_KEY_MAX,
221
+ systemText,
222
+ } from './prompt-cache';
180
223
  export { promptFences } from './prompt-fence';
181
224
  export type {
182
225
  AiContentBlock,
@@ -230,6 +273,7 @@ export {
230
273
  configureAi,
231
274
  semanticCacheFor,
232
275
  } from './runtime';
276
+ export type { Sampling } from './sampling';
233
277
  export type { Scorer } from './scorers';
234
278
  export {
235
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
+ }