@ultimat3/ai 27.8.0 → 27.9.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.
@@ -0,0 +1,37 @@
1
+ // Single responsibility: the system prompt ONE agent run sends — the prompt's static text and what
2
+ // the declaration's `system()` loaded, composed into the stable and per-run parts a cache keys on.
3
+
4
+ import type { SystemPrompt } from './prompt-cache';
5
+
6
+ /**
7
+ * What `system()` adds to the prompt's own `system`. `stable` joins it in the block every run
8
+ * shares — it must be BYTE-identical run to run (sorted, undated), or each run pays a full cache
9
+ * write for it. `run` is a second block after it, for whatever differs.
10
+ */
11
+ export interface AgentSystem {
12
+ readonly stable?: string;
13
+ readonly run?: string;
14
+ }
15
+
16
+ /**
17
+ * The system prompt one run sends: the prompt's static text and `system()`'s stable part as ONE
18
+ * block (the half every run shares), the per-run part after it. A plain string when there is no
19
+ * per-run part — the shape every request had before the parts existed.
20
+ */
21
+ export function systemOf(
22
+ fixed: string | undefined,
23
+ extra: AgentSystem,
24
+ redact: (text: string) => string,
25
+ ): SystemPrompt | undefined {
26
+ const stable = [fixed ?? '', extra.stable ?? '']
27
+ .filter((part) => part !== '')
28
+ .map(redact)
29
+ .join('\n\n');
30
+ const run = extra.run === undefined || extra.run === '' ? '' : redact(extra.run);
31
+ if (run !== '') return { stable, run };
32
+ return fixed === undefined && stable === '' ? undefined : stable;
33
+ }
34
+
35
+ /** Whether two composed prompts are the same text — what `llm.redacted` compares. */
36
+ export const sameSystem = (a: SystemPrompt | undefined, b: SystemPrompt | undefined): boolean =>
37
+ JSON.stringify(a) === JSON.stringify(b);
@@ -0,0 +1,27 @@
1
+ // Single responsibility: a run's running token and money total, across its turns.
2
+
3
+ import type { Money } from '@ultimat3/money';
4
+ import type { GenerateResult, TokenUsage } from './provider';
5
+ import { costOf } from './provider';
6
+
7
+ /** The run so far, this turn included. */
8
+ export interface AgentRunUsage {
9
+ readonly usage: TokenUsage;
10
+ readonly cost: Money;
11
+ }
12
+
13
+ /**
14
+ * The run's total after one more turn. The cost is re-priced from the summed usage rather than
15
+ * summed per turn: each turn's cost is rounded UP to a minor unit, and ten round-ups is not the
16
+ * price of the run.
17
+ */
18
+ export function addUsage(total: AgentRunUsage | undefined, result: GenerateResult): AgentRunUsage {
19
+ const before = total?.usage;
20
+ const usage: TokenUsage = {
21
+ inputTokens: (before?.inputTokens ?? 0) + result.usage.inputTokens,
22
+ outputTokens: (before?.outputTokens ?? 0) + result.usage.outputTokens,
23
+ cacheReadTokens: (before?.cacheReadTokens ?? 0) + result.usage.cacheReadTokens,
24
+ cacheWriteTokens: (before?.cacheWriteTokens ?? 0) + result.usage.cacheWriteTokens,
25
+ };
26
+ return { usage, cost: costOf(result.model, usage) };
27
+ }
package/src/agent.ts CHANGED
@@ -20,38 +20,36 @@
20
20
  * one run's world state to another. Version bump plus the tools' own caching is the story.
21
21
  */
22
22
 
23
- import type { Action, ActionMcp, ActionPolicy } from '@ultimat3/action';
23
+ import type { Action } from '@ultimat3/action';
24
24
  import { action } from '@ultimat3/action';
25
25
  import type { Ctx, Span } from '@ultimat3/core';
26
26
  import { finiteCount, isMcpExposed, throwIfAborted, withSpan } from '@ultimat3/core';
27
- import type { Money } from '@ultimat3/money';
28
27
  import type { InferOutput, StandardSchemaV1 } from '@ultimat3/schema';
29
28
  import { formatIssues, validateAsync } from '@ultimat3/schema';
29
+ import type { AgentDef, AgentTurn } from './agent-def';
30
30
  import type { AgentFact } from './agent-facts';
31
31
  import { registerAgentFact } from './agent-facts';
32
+ import { DEFAULT_MAX_REPEATED_TOOL_CALLS, runDeadline, toolLoopGuard } from './agent-guards';
33
+ import { sameSystem, systemOf } from './agent-system';
32
34
  import { assistantTurn, repairTurn, toolResultTurn } from './agent-transcript';
35
+ import type { AgentRunUsage } from './agent-usage';
36
+ import { addUsage } from './agent-usage';
33
37
  import type { BudgetLimits } from './budget';
34
38
  import { budgetKeysFor, currentBudget, screenedMoney, withBudget } from './budget';
35
39
  import {
36
40
  AgentMaxTurnsError,
37
41
  AgentToolUnexposedError,
42
+ AiRequestInvalidError,
38
43
  LlmOutputInvalidError,
39
44
  LlmRefusedError,
40
45
  LlmTruncatedError,
41
46
  } from './errors';
42
- import type { LlmBudget } from './llm';
43
47
  import { answerAttributes } from './llm';
44
48
  import { describedModel, type ModelSource, modelForCall } from './model-resolve';
45
- import type { ModelId } from './models';
46
49
  import { moreCapableThan } from './models';
47
- import type { Prompt, PromptVars } from './prompt';
48
- import type {
49
- AiMessage,
50
- GenerateRequest,
51
- GenerateResult,
52
- StopReason,
53
- TokenUsage,
54
- } from './provider';
50
+ import type { PromptVars } from './prompt';
51
+ import { promptCacheHint } from './prompt-cache';
52
+ import type { AiMessage, GenerateRequest, GenerateResult } from './provider';
55
53
  import { isTruncated } from './provider';
56
54
  import { assertNoSecrets } from './redaction';
57
55
  import type { Respond } from './respond';
@@ -80,76 +78,7 @@ const DEFAULT_TOOL_RESULT_CHARS = 4_000;
80
78
  /** Named in every bound refusal, so the fix names the key the declaration carries. */
81
79
  const SUBJECT = 'agent';
82
80
 
83
- export interface AgentBudget extends LlmBudget {
84
- /**
85
- * Token ceiling for the WHOLE run, every turn counted. The one ceiling `llm()` does not need:
86
- * a single call is bounded by `maxTokens`, a loop is bounded by nothing until this is set.
87
- */
88
- readonly tokensPerRun?: number;
89
- }
90
-
91
- export interface AgentVarsArgs<TInput extends StandardSchemaV1> {
92
- readonly input: InferOutput<TInput>;
93
- readonly ctx: Ctx;
94
- }
95
-
96
- export interface AgentDef<
97
- TInput extends StandardSchemaV1,
98
- TOutput extends StandardSchemaV1,
99
- V extends PromptVars,
100
- > {
101
- readonly model?: ModelId;
102
- readonly input: TInput;
103
- readonly output: TOutput;
104
- readonly prompt: Prompt<V>;
105
- /** Same contract as `llm()`: the one declared place a run loads data. */
106
- vars(args: AgentVarsArgs<TInput>): V | Promise<V>;
107
- /**
108
- * The actions the model may call. Each must be `mcp: { expose: true }` — the same predicate an
109
- * external MCP client is filtered by, so an in-app agent and an external one are offered
110
- * exactly the same tools. Listing one that is not exposed is refused at declaration rather than
111
- * dropped, because a tool that reads as offered and silently is not is the worst of both.
112
- */
113
- readonly tools: readonly AgentTool[];
114
- /** Hard ceiling on model turns. Reaching it is `X_AGENT_MAX_TURNS`, never a partial answer. */
115
- readonly maxTurns?: number;
116
- readonly maxToolResultChars?: number;
117
- readonly budget?: AgentBudget;
118
- readonly policy: ActionPolicy;
119
- readonly mcp?: ActionMcp;
120
- /** Enforced completion ceiling PER TURN. The model never sees it. */
121
- readonly maxTokens?: number;
122
- /**
123
- * Called once per completed model turn, before the answer or the tool calls are acted on. The
124
- * one thing a multi-turn run could not do until 2026-08: a 90-second loop emitted nothing until
125
- * it returned, so a progress indicator, a per-turn spend line and a transcript log all had to be
126
- * hand-rolled outside the framework — which is exactly the loop `agent()` exists to replace.
127
- *
128
- * Observation only: it cannot steer the loop, cannot see the transcript and cannot reach the
129
- * actor. A throw from it FAILS the run rather than being swallowed — it is the app's code on the
130
- * run's own path, and an observer that silently stopped working would be indistinguishable from
131
- * one that is fine. The same facts land on the span as an `agent.turn` event, so a run that
132
- * declares no hook is still readable in a trace.
133
- *
134
- * NOT `.stream()`. Tokens on a screen is a different contract, and `llm()`'s streamed half is
135
- * one turn deep by construction; this is per TURN.
136
- */
137
- onTurn?(event: AgentTurn): void | Promise<void>;
138
- }
139
-
140
- /** One completed model turn, as an observer sees it. Facts only — no transcript, no actor. */
141
- export interface AgentTurn {
142
- /** 1-based, so `turn === maxTurns` is the last one this run will take. */
143
- readonly turn: number;
144
- readonly maxTurns: number;
145
- readonly model: ModelId;
146
- /** Tools this turn asked for, in the order the model emitted them. `respond` is not one. */
147
- readonly toolCalls: readonly string[];
148
- readonly stopReason: StopReason;
149
- readonly usage: TokenUsage;
150
- /** This turn's cost alone, integer minor units — never the run's running total. */
151
- readonly cost: Money;
152
- }
81
+ export type { AgentBudget, AgentDef, AgentTurn, AgentVarsArgs } from './agent-def';
153
82
 
154
83
  export function agent<
155
84
  TInput extends StandardSchemaV1,
@@ -183,6 +112,21 @@ export function agent<
183
112
  1,
184
113
  );
185
114
  finiteCount(SUBJECT, 'maxTokens', def.maxTokens ?? DEFAULT_MAX_TOKENS, 1);
115
+ const repeats = finiteCount(
116
+ SUBJECT,
117
+ 'maxRepeatedToolCalls',
118
+ def.maxRepeatedToolCalls ?? DEFAULT_MAX_REPEATED_TOOL_CALLS,
119
+ 1,
120
+ );
121
+ if (repeats < 2) {
122
+ // One "repeat" is the first tool turn of every run: the agent could never use a tool.
123
+ throw new AiRequestInvalidError({
124
+ detail: `agent maxRepeatedToolCalls is ${repeats}, which would stop every run at its first tool turn`,
125
+ fix: 'maxRepeatedToolCalls: 3 # on the agent — at least 2; it counts consecutive identical turns, the first included',
126
+ });
127
+ }
128
+ // `AbortSignal.timeout(NaN)` throws a RangeError mid-run; `0` aborts before turn one.
129
+ if (def.deadlineMs !== undefined) finiteCount(SUBJECT, 'deadlineMs', def.deadlineMs, 1);
186
130
  // Projected on FIRST RUN, memoised, for the reason above: `agent()` is evaluated at module
187
131
  // scope and a tool's name is stamped by `registerAction` at boot, so naming it here would make
188
132
  // the ordinary `export const publishPost = action(...)` beside it `X_ACTION_UNREGISTERED`.
@@ -267,12 +211,19 @@ async function run<
267
211
  const limits = limitsOf(def);
268
212
  // The declaration, its prompt, then the gateway's `defaultModel` — the app's three places.
269
213
  const model = modelForCall('agent', name, def.model, prompt.model);
270
- const vars = await def.vars({ input: args.input, ctx: args.ctx });
214
+ // Armed before anything is loaded: `vars()` is part of the run a deadline bounds.
215
+ const deadline = runDeadline(name, def.deadlineMs, args.ctx.signal);
216
+ const vars = await deadline.guard(Promise.resolve(def.vars(args)));
271
217
  assertNoSecrets(name, vars);
272
218
  const redact = aiRedactor();
273
219
  const rawPrompt = prompt.render(vars);
274
220
  const rendered = redact(rawPrompt);
275
- const system = prompt.system === undefined ? undefined : redact(prompt.system);
221
+ const extra =
222
+ def.system === undefined ? {} : await deadline.guard(Promise.resolve(def.system(args)));
223
+ const rawSystem = systemOf(prompt.system, extra, (text) => text);
224
+ const system = systemOf(prompt.system, extra, redact);
225
+ const promptCache = promptCacheHint(def.promptCache, 'auto', args);
226
+ const repeated = toolLoopGuard(name, def.maxRepeatedToolCalls ?? DEFAULT_MAX_REPEATED_TOOL_CALLS);
276
227
  const maxTurns = def.maxTurns ?? DEFAULT_MAX_TURNS;
277
228
  const chars = def.maxToolResultChars ?? DEFAULT_TOOL_RESULT_CHARS;
278
229
 
@@ -283,7 +234,8 @@ async function run<
283
234
  'agent.prompt.hash': prompt.hash,
284
235
  'agent.tools': adapted.offered.length,
285
236
  'agent.max_turns': maxTurns,
286
- 'llm.redacted': rendered !== rawPrompt || system !== prompt.system,
237
+ 'llm.redacted': rendered !== rawPrompt || !sameSystem(system, rawSystem),
238
+ 'llm.prompt_cache': promptCache !== undefined,
287
239
  });
288
240
 
289
241
  // Rooted in the GATEWAY's own ceilings when no scope is open — an empty root ignored them.
@@ -295,6 +247,7 @@ async function run<
295
247
  const base: GenerateRequest = {
296
248
  model,
297
249
  ...(system === undefined ? {} : { system }),
250
+ ...(promptCache === undefined ? {} : { promptCache }),
298
251
  messages: [],
299
252
  maxTokens: def.maxTokens ?? DEFAULT_MAX_TOKENS,
300
253
  ...(prompt.effort === undefined ? {} : { effort: prompt.effort }),
@@ -302,7 +255,8 @@ async function run<
302
255
  tools: [...adapted.offered, respond.tool],
303
256
  // The caller's own signal, forwarded to the transport. A disconnect has to reach the socket,
304
257
  // not just the top of the next turn: a provider call already in flight is the expensive one.
305
- signal: args.ctx.signal,
258
+ // The run's deadline is folded into the same signal, so it reaches the socket too.
259
+ signal: deadline.signal,
306
260
  };
307
261
 
308
262
  return withBudget(ledger, async () => {
@@ -313,13 +267,23 @@ async function run<
313
267
  let messages: readonly AiMessage[] = [{ role: 'user', content: rendered }];
314
268
  let calls = 0;
315
269
  let issues: string | undefined;
270
+ let total: AgentRunUsage | undefined;
316
271
 
317
272
  for (let turn = 1; turn <= maxTurns; turn += 1) {
318
273
  // The caller is gone, so unwind instead of finishing. Every turn re-sends the WHOLE
319
274
  // transcript, so a loop that keeps going after a disconnect spends the rest of the run's
320
275
  // budget, runs every remaining tool's side effects, and discards the answer.
321
276
  throwIfAborted(args.ctx);
322
- const result = await gateway.generate({ ...base, messages });
277
+ deadline.check();
278
+ const result = await deadline
279
+ .guard(gateway.generate({ ...base, messages }))
280
+ .catch((error: unknown) => {
281
+ // The caller left mid-call: the aborted socket reads as a provider failure, which is
282
+ // RETRYABLE. Say what happened instead.
283
+ throwIfAborted(args.ctx);
284
+ throw error;
285
+ });
286
+ total = addUsage(total, result);
323
287
  const requested = result.toolCalls.filter((call) => call.name !== RESPOND);
324
288
  // The same turn's `respond` calls, kept rather than forgotten: they are never RUN, but
325
289
  // `assistantTurn` replays them, and every replayed `tool_use` has to be answered.
@@ -337,6 +301,7 @@ async function run<
337
301
  stopReason: result.stopReason,
338
302
  usage: result.usage,
339
303
  cost: result.cost,
304
+ run: total,
340
305
  });
341
306
  assertAnswerable(result, name, base);
342
307
 
@@ -352,10 +317,17 @@ async function run<
352
317
  // result carries the `tool_use` id `runLlmToolCall` was handed, so a fast tool answering
353
318
  // first cannot be paired with a slow tool's call.
354
319
  throwIfAborted(args.ctx);
355
- const results: readonly LlmToolResult[] = await Promise.all(
356
- requested.map((call) => runLlmToolCall(adapted.tools, call, actor)),
320
+ deadline.check();
321
+ const results: readonly LlmToolResult[] = await deadline.guard(
322
+ // Inside the deadline's scope, so each tool's own `ctx.signal` carries it.
323
+ deadline.scope(() =>
324
+ Promise.all(requested.map((call) => runLlmToolCall(adapted.tools, call, actor))),
325
+ ),
357
326
  );
358
327
  calls += requested.length;
328
+ // Before the next turn is bought: the same calls with the same results cannot lead
329
+ // anywhere a further turn would not also lead.
330
+ repeated(requested, results);
359
331
  messages = [
360
332
  ...messages,
361
333
  assistantTurn(result),
@@ -408,6 +380,10 @@ async function observeTurn<
408
380
  'agent.turn.tool_calls': event.toolCalls.length,
409
381
  'llm.stop': event.stopReason,
410
382
  'llm.cost.minor': event.cost.minor,
383
+ // What the provider's prompt cache did for this turn — the two numbers that say whether the
384
+ // stable prefix is really stable.
385
+ 'llm.cache.read_tokens': event.usage.cacheReadTokens,
386
+ 'llm.cache.write_tokens': event.usage.cacheWriteTokens,
411
387
  });
412
388
  await def.onTurn?.(event);
413
389
  }
package/src/budget.ts CHANGED
@@ -57,11 +57,12 @@ export interface SpendEstimate {
57
57
  }
58
58
 
59
59
  /** Price a request pre-flight. The pessimistic read on purpose — see `estimateCost`. */
60
- export function estimateSpend(request: GenerateRequest): SpendEstimate {
60
+ export function estimateSpend(request: GenerateRequest, marked = false): SpendEstimate {
61
61
  return {
62
62
  inputTokens: estimateInputTokens(request),
63
63
  tokens: estimateTokens(request),
64
- cost: estimateCost(request),
64
+ // `marked`: a provider would send this request with a cache mark, so it can bill as a write.
65
+ cost: estimateCost(request, marked),
65
66
  };
66
67
  }
67
68
 
@@ -38,6 +38,12 @@ export const AI_ERROR_RETRY = {
38
38
  // be both, and registering the code here is what makes the override honoured: core reads an
39
39
  // instance `terminal` on an UNREGISTERED code as unclassified.
40
40
  X_AI_PROVIDER_UNAVAILABLE: 'retryable',
41
+ // The two stops an unattended `agent()` run makes, REGISTERED terminal — the deliberate decision
42
+ // the paragraph above leaves to whoever makes it. A run stopped for repeating itself repeats
43
+ // itself again, and a run that outlived its declared deadline is the same run against the same
44
+ // deadline: unclassified, an `agentJob` with `attempts: 3` paid for it three times.
45
+ X_AGENT_TOOL_LOOP: 'terminal',
46
+ X_AGENT_DEADLINE: 'terminal',
41
47
  } as const satisfies Readonly<Partial<Record<AiErrorCode, ErrorRetry>>>;
42
48
 
43
49
  // `Partial`, so the table may be a subset — but every key is still checked against the declared set,
package/src/errors.ts CHANGED
@@ -30,6 +30,8 @@ export const AI_ERROR_CODES = [
30
30
  'X_VECTOR_UNSCOPED',
31
31
  'X_AI_CONTENT_UNSUPPORTED',
32
32
  'X_AI_MODEL_UNRESOLVED',
33
+ 'X_AGENT_TOOL_LOOP',
34
+ 'X_AGENT_DEADLINE',
33
35
  ] as const;
34
36
 
35
37
  export type AiErrorCode = (typeof AI_ERROR_CODES)[number];
@@ -61,6 +63,8 @@ export const AI_ERROR_TITLES: Readonly<Record<AiErrorCode, string>> = {
61
63
  X_VECTOR_UNSCOPED: 'a vector store was read with no tenant bound inside an org request',
62
64
  X_AI_CONTENT_UNSUPPORTED: 'an image or document block the role, model or wire format cannot take',
63
65
  X_AI_MODEL_UNRESOLVED: 'a model call named no model',
66
+ X_AGENT_TOOL_LOOP: 'an agent repeated one tool call with one result and was stopped',
67
+ X_AGENT_DEADLINE: 'an agent ran past its declared deadline and was stopped',
64
68
  };
65
69
 
66
70
  // Titles must be registered for `format()` to render the contract's first line. Unconditional and
package/src/gateway.ts CHANGED
@@ -180,7 +180,7 @@ class GatewayImpl implements Gateway {
180
180
  // The estimate is DEBITED here, not merely checked: three concurrent calls under one ledger
181
181
  // all read the same `spent()` otherwise, all pass, and all three record against a ceiling
182
182
  // only one of them fitted.
183
- const reservation = await ledger?.reserve(estimateSpend(resolved));
183
+ const reservation = await ledger?.reserve(estimateSpend(resolved, this.marks(model)));
184
184
 
185
185
  let result: GenerateResult;
186
186
  try {
@@ -230,7 +230,7 @@ class GatewayImpl implements Gateway {
230
230
  const provider = this.providerFor(model);
231
231
 
232
232
  const ledger = currentBudget() ?? this.callLedger({});
233
- const reservation = await ledger?.reserve(estimateSpend(resolved));
233
+ const reservation = await ledger?.reserve(estimateSpend(resolved, this.marks(model)));
234
234
  let settled = false;
235
235
  try {
236
236
  for await (const chunk of provider.stream(resolved)) {
@@ -251,6 +251,13 @@ class GatewayImpl implements Gateway {
251
251
  }
252
252
  }
253
253
 
254
+ /** Whether any provider serving `model` would send a hinted request with a cache mark. */
255
+ private marks(model: ModelId): boolean {
256
+ return this.config.providers.some(
257
+ (p) => p.models.includes(model) && p.marksPromptCache?.(model) === true,
258
+ );
259
+ }
260
+
254
261
  private providerFor(model: ModelId): Provider {
255
262
  const provider = this.config.providers.find((p) => p.models.includes(model));
256
263
  if (provider === undefined) {
package/src/index.ts CHANGED
@@ -4,12 +4,15 @@
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 { AgentBudget, AgentDef, AgentTurn, AgentVarsArgs } from './agent-def';
9
+ export { AgentDeadlineError, AgentToolLoopError } from './agent-errors';
9
10
  export type { AgentBudgetFact, AgentFact } from './agent-facts';
10
11
  export { describeAgents, resetAgents } from './agent-facts';
11
12
  export type { AgentJobOptions } from './agent-job';
12
13
  export { agentJob } from './agent-job';
14
+ export type { AgentSystem } from './agent-system';
15
+ export type { AgentRunUsage } from './agent-usage';
13
16
  export type {
14
17
  BudgetKeys,
15
18
  BudgetLedger,
@@ -177,6 +180,21 @@ export {
177
180
  promptVersions,
178
181
  resetPrompts,
179
182
  } from './prompt';
183
+ export type {
184
+ CacheWire,
185
+ PromptCacheHint,
186
+ PromptCacheOption,
187
+ PromptCacheRule,
188
+ SystemParts,
189
+ SystemPrompt,
190
+ } from './prompt-cache';
191
+ export {
192
+ OPENAI_CACHE_KEY_MAX,
193
+ PROMPT_CACHE_RULES,
194
+ promptCacheRule,
195
+ SESSION_KEY_MAX,
196
+ systemText,
197
+ } from './prompt-cache';
180
198
  export { promptFences } from './prompt-fence';
181
199
  export type {
182
200
  AiContentBlock,
package/src/llm.ts CHANGED
@@ -45,6 +45,8 @@ import { modelForCall } from './model-resolve';
45
45
  import type { ModelId } from './models';
46
46
  import { moreCapableThan } from './models';
47
47
  import type { Prompt, PromptVars } from './prompt';
48
+ import type { PromptCacheOption } from './prompt-cache';
49
+ import { promptCacheHint } from './prompt-cache';
48
50
  import type { AiMessage, GenerateRequest, GenerateResult } from './provider';
49
51
  import { isTruncated } from './provider';
50
52
  import { assertNoSecrets } from './redaction';
@@ -94,6 +96,13 @@ export interface LlmDef<
94
96
  */
95
97
  vars(args: LlmVarsArgs<TInput>): V | Promise<V>;
96
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>>;
97
106
  readonly budget?: LlmBudget;
98
107
  readonly policy: ActionPolicy;
99
108
  readonly mcp?: ActionMcp;
@@ -207,6 +216,7 @@ async function generate<
207
216
  const rendered = redact(rawPrompt);
208
217
  const system = prompt.system === undefined ? undefined : redact(prompt.system);
209
218
  const redacted = rendered !== rawPrompt || system !== prompt.system;
219
+ const promptCache = promptCacheHint(def.promptCache, 'off', args);
210
220
 
211
221
  return withSpan('ai.llm', async (span) => {
212
222
  span.setAttributes({
@@ -239,6 +249,7 @@ async function generate<
239
249
  const base: GenerateRequest = {
240
250
  model,
241
251
  ...(system === undefined ? {} : { system }),
252
+ ...(promptCache === undefined ? {} : { promptCache }),
242
253
  messages: [{ role: 'user', content: rendered }],
243
254
  maxTokens: def.maxTokens ?? DEFAULT_MAX_TOKENS,
244
255
  ...(prompt.effort === undefined ? {} : { effort: prompt.effort }),
@@ -9,6 +9,7 @@ import { AiRequestInvalidError } from './errors';
9
9
  import type { Effort, ModelId, ThinkingMode } from './models';
10
10
  import { modelSpec } from './models';
11
11
  import { toOpenAiMessages, toOpenAiTools, toolChoiceFor } from './openai-messages';
12
+ import { OPENAI_CACHE_KEY_MAX, promptCacheRule, SESSION_KEY_MAX } from './prompt-cache';
12
13
  import type { GenerateRequest } from './provider';
13
14
 
14
15
  export interface ChatCompletionBodyInput {
@@ -18,6 +19,8 @@ export interface ChatCompletionBodyInput {
18
19
  /** The provider's `name`, so a content refusal names the endpoint that cannot take the block. */
19
20
  readonly provider: string;
20
21
  readonly stream: boolean;
22
+ /** The endpoint, for `PROMPT_CACHE_RULES` — which host this is decides what a cache hint sends. */
23
+ readonly baseUrl?: string | undefined;
21
24
  }
22
25
 
23
26
  /**
@@ -33,9 +36,12 @@ export function chatCompletionBody(input: ChatCompletionBodyInput): Record<strin
33
36
  const { request, model, stream, provider } = input;
34
37
  const spec = modelSpec(model);
35
38
  assertMediaContent(request.messages, { provider, model });
39
+ const hint = request.promptCache;
40
+ const rule = promptCacheRule('openai', input.baseUrl, model);
41
+ const marked = hint !== undefined && rule.marks === 'parts';
36
42
  const body: Record<string, unknown> = {
37
43
  model,
38
- messages: toOpenAiMessages(request.system, request.messages, { provider, model }),
44
+ messages: toOpenAiMessages(request.system, request.messages, { provider, model }, marked),
39
45
  max_completion_tokens: Math.min(request.maxTokens, spec.maxOutput),
40
46
  ...reasoningFields(model, request.effort, request.thinking),
41
47
  };
@@ -47,6 +53,11 @@ export function chatCompletionBody(input: ChatCompletionBodyInput): Record<strin
47
53
  if (request.stopSequences !== undefined && request.stopSequences.length > 0) {
48
54
  body['stop'] = request.stopSequences;
49
55
  }
56
+ // OpenAI's own grouping key. OpenRouter's session rides a header instead (`sessionHeaders`), so
57
+ // the body never carries two spellings of one key.
58
+ if (hint?.session !== undefined && rule.session === 'prompt_cache_key') {
59
+ body['prompt_cache_key'] = cacheKey(hint.session);
60
+ }
50
61
  if (stream) {
51
62
  body['stream'] = true;
52
63
  // Without this the final chunk carries no `usage` and the budget reconciles against nothing —
@@ -57,6 +68,44 @@ export function chatCompletionBody(input: ChatCompletionBodyInput): Record<strin
57
68
  return body;
58
69
  }
59
70
 
71
+ /** The headers a cache hint adds for this endpoint and model: OpenRouter's stickiness key, or none. */
72
+ export function sessionHeaders(
73
+ request: GenerateRequest,
74
+ model: ModelId,
75
+ baseUrl: string | undefined,
76
+ ): Readonly<Record<string, string>> {
77
+ const session = request.promptCache?.session;
78
+ if (session === undefined) return {};
79
+ if (promptCacheRule('openai', baseUrl, model).session !== 'x-session-id') return {};
80
+ return { 'x-session-id': sessionKey(session) };
81
+ }
82
+
83
+ /**
84
+ * OpenAI's grouping key. Never cut — a truncated key is a different key two tenants can share —
85
+ * and never refused for length: over `OPENAI_CACHE_KEY_MAX` it is sent as a hash of itself, which
86
+ * groups the same requests. `h:` plus 62 hex characters of its SHA-256.
87
+ */
88
+ function cacheKey(session: string): string {
89
+ if (session === '') return sessionKey(session);
90
+ if (session.length <= OPENAI_CACHE_KEY_MAX) return session;
91
+ const digest = new Bun.CryptoHasher('sha256').update(session).digest('hex');
92
+ return `h:${digest.slice(0, OPENAI_CACHE_KEY_MAX - 2)}`;
93
+ }
94
+
95
+ /**
96
+ * OpenRouter's header. Refused, never cut: a truncated key is a DIFFERENT key that two tenants can
97
+ * share, and a control character in a header value is a request the runtime refuses with no code.
98
+ */
99
+ function sessionKey(session: string): string {
100
+ if (session !== '' && session.length <= SESSION_KEY_MAX && !/[^\x20-\x7e]/.test(session)) {
101
+ return session;
102
+ }
103
+ throw new AiRequestInvalidError({
104
+ detail: `the prompt-cache session key is ${session.length} characters; it must be 1 to ${SESSION_KEY_MAX} printable ASCII characters to travel as a header`,
105
+ fix: `promptCache: { session: 'kb-agent' } # at most ${SESSION_KEY_MAX} printable ASCII characters (the x-session-id header's limit): a short, stable, non-secret name`,
106
+ });
107
+ }
108
+
60
109
  /**
61
110
  * The reasoning half, shaped for one model. Refused LOCALLY when the model's spec says the endpoint
62
111
  * has no such control, for the reason models.ts states: a round trip to learn a rule the registry