@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/llm.ts CHANGED
@@ -21,36 +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';
42
+ import type { PromptVars } from './prompt';
43
+ import { promptCacheHint } from './prompt-cache';
48
44
  import type { AiMessage, GenerateRequest, GenerateResult } from './provider';
49
45
  import { isTruncated } from './provider';
50
46
  import { assertNoSecrets } from './redaction';
51
47
  import type { Respond } from './respond';
52
48
  import { parseJsonish, RESPOND, respondFor } from './respond';
53
- 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 { ignoredSampling, screenSampling } from './sampling';
54
54
 
55
55
  /** Two attempts total: the answer, then one repair turn. See `LlmOutputInvalidError`. */
56
56
  const ATTEMPTS = 2;
@@ -61,45 +61,7 @@ const ATTEMPTS = 2;
61
61
  */
62
62
  const DEFAULT_MAX_TOKENS = 4_096;
63
63
 
64
- /** Per-call ceilings, checked before the provider is reached. Never truncates — refuses. */
65
- export interface LlmBudget {
66
- /** Prompt tokens. */
67
- readonly tokensIn?: number;
68
- /** Worst-case price of one call, integer minor units. */
69
- readonly costPerCall?: Money;
70
- }
71
-
72
- export interface LlmVarsArgs<TInput extends StandardSchemaV1> {
73
- readonly input: InferOutput<TInput>;
74
- readonly ctx: Ctx;
75
- }
76
-
77
- export interface LlmDef<
78
- TInput extends StandardSchemaV1,
79
- TOutput extends StandardSchemaV1,
80
- V extends PromptVars,
81
- > {
82
- /**
83
- * Overrides the prompt artifact's own model. The prompt's is part of its content hash and
84
- * travels with it; this one is the deployment decision, so it wins.
85
- */
86
- readonly model?: ModelId;
87
- readonly input: TInput;
88
- readonly output: TOutput;
89
- readonly prompt: Prompt<V>;
90
- /**
91
- * Everything the prompt is allowed to see, derived from the parsed input. Required, and
92
- * async, because the input is usually an id and the prompt needs the row behind it — this
93
- * is the one declared place a model call loads data, so a reader can see what was sent.
94
- */
95
- vars(args: LlmVarsArgs<TInput>): V | Promise<V>;
96
- readonly cache?: LlmCache<InferOutput<TInput>>;
97
- readonly budget?: LlmBudget;
98
- readonly policy: ActionPolicy;
99
- readonly mcp?: ActionMcp;
100
- /** Enforced completion ceiling. The model never sees it, so it can be cut off mid-answer. */
101
- readonly maxTokens?: number;
102
- }
64
+ export type { LlmBudget, LlmCall, LlmDef, LlmVarsArgs } from './llm-def';
103
65
 
104
66
  /**
105
67
  * An `action` with one extra way to be called. Every projection an action has, it has — its MCP tool,
@@ -143,12 +105,13 @@ export function llm<
143
105
  // off for the life of the process. A floor of 1 because a completion ceiling of zero tokens is
144
106
  // a call that cannot answer.
145
107
  finiteCount('llm', 'maxTokens', def.maxTokens ?? DEFAULT_MAX_TOKENS, 1);
108
+ const sampling = screenSampling('llm', def);
146
109
  const built = action<TInput, TOutput>({
147
110
  input: def.input,
148
111
  output: def.output,
149
112
  policy: def.policy,
150
113
  ...(def.mcp === undefined ? {} : { mcp: def.mcp }),
151
- handle: (args) => generate(def, respond, args),
114
+ handle: (args) => generate(def, respond, sampling, args),
152
115
  });
153
116
  return streamable(built);
154
117
  }
@@ -186,6 +149,7 @@ async function generate<
186
149
  >(
187
150
  def: LlmDef<TInput, TOutput, V>,
188
151
  respond: Respond,
152
+ sampling: Sampling,
189
153
  args: { readonly input: InferOutput<TInput>; readonly ctx: Ctx },
190
154
  ): Promise<InferOutput<TOutput>> {
191
155
  const { prompt } = def;
@@ -207,6 +171,7 @@ async function generate<
207
171
  const rendered = redact(rawPrompt);
208
172
  const system = prompt.system === undefined ? undefined : redact(prompt.system);
209
173
  const redacted = rendered !== rawPrompt || system !== prompt.system;
174
+ const promptCache = promptCacheHint(def.promptCache, 'off', args);
210
175
 
211
176
  return withSpan('ai.llm', async (span) => {
212
177
  span.setAttributes({
@@ -217,6 +182,11 @@ async function generate<
217
182
  // an audit asks evidence for, and a redactor that silently stopped matching looks identical
218
183
  // to one that had nothing to remove until this attribute separates them.
219
184
  'llm.redacted': redacted,
185
+ 'llm.sampling.ignored': ignoredSampling(
186
+ model,
187
+ sampling,
188
+ installedGateway()?.samplingWires?.(model),
189
+ ).join(','),
220
190
  });
221
191
 
222
192
  // A cached answer is still data of unknown provenance, so it goes through the schema like
@@ -239,10 +209,12 @@ async function generate<
239
209
  const base: GenerateRequest = {
240
210
  model,
241
211
  ...(system === undefined ? {} : { system }),
212
+ ...(promptCache === undefined ? {} : { promptCache }),
242
213
  messages: [{ role: 'user', content: rendered }],
243
214
  maxTokens: def.maxTokens ?? DEFAULT_MAX_TOKENS,
244
215
  ...(prompt.effort === undefined ? {} : { effort: prompt.effort }),
245
216
  ...(prompt.thinking === undefined ? {} : { thinking: prompt.thinking }),
217
+ ...sampling,
246
218
  // The caller's own signal, forwarded to the transport exactly as `agent()` does — and
247
219
  // inherited by `streamedAnswer` from this same `base`. Without it a disconnected caller left
248
220
  // the provider call in flight, billed and unread, and the repair turn bought a SECOND one.
@@ -261,17 +233,22 @@ async function generate<
261
233
  );
262
234
  const sink = currentLlmSink();
263
235
 
264
- return withBudget(ledger, async () => {
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>> => {
265
240
  if (sink !== undefined) {
266
- const value = await streamedAnswer(def.output, name, gateway, base, sink, span);
241
+ const value = await streamedAnswer(def, name, gateway, base, sink, span, meter);
267
242
  await cache?.remember(value);
268
243
  return value;
269
244
  }
270
245
  let messages: readonly AiMessage[] = request.messages;
271
246
  let issues = 'no output';
247
+ let rejected: readonly string[] | undefined;
272
248
  for (let attempt = 1; attempt <= ATTEMPTS; attempt += 1) {
273
- const result = await gateway.generate({ ...request, messages });
249
+ const result = await meter.call(gateway.generate({ ...request, messages }));
274
250
  span.setAttributes({ 'llm.attempts': attempt, ...answerAttributes(result) });
251
+ await observeCall(def, meter, result, attempt, rejected);
275
252
  // Branch on the stop reason BEFORE reading the answer. A refusal carries empty or partial
276
253
  // content, so parsing it first reports a schema disagreement — a cause that is wrong, a
277
254
  // fix that does not apply, and a repair turn spent buying the same refusal again.
@@ -287,25 +264,38 @@ async function generate<
287
264
  explanation: result.stopDetails?.explanation,
288
265
  });
289
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
+ }
290
274
  const parsed = await validateAsync(def.output, respond.read(result));
291
275
  if (parsed.issues === undefined) {
292
276
  await cache?.remember(parsed.value);
293
277
  return parsed.value;
294
278
  }
295
- // A cut-off answer that also fails its schema is not a disagreement about the shape: the
296
- // ceiling is the same on the next attempt, so the repair turn is a second truncation.
297
- if (isTruncated(result.stopReason)) {
298
- throw new LlmTruncatedError({ prompt: name, maxTokens: request.maxTokens });
299
- }
300
- issues = formatIssues(parsed.issues).join('; ');
279
+ rejected = formatIssues(parsed.issues);
280
+ issues = rejected.join('; ');
301
281
  const echo = assistantEcho(result);
302
282
  messages =
303
283
  echo === undefined
304
284
  ? [...messages, repair(issues)]
305
285
  : [...messages, { role: 'assistant', content: echo }, repair(issues)];
306
286
  }
307
- throw new LlmOutputInvalidError({ prompt: name, attempts: ATTEMPTS, issues });
308
- });
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
+ );
309
299
  });
310
300
  }
311
301
 
@@ -334,16 +324,23 @@ export function answerAttributes(result: GenerateResult): SpanAttributes {
334
324
  * carries empty or partial content and parsing it first reports a schema disagreement that is not
335
325
  * one. Truncation is the same call it is on the non-streaming path.
336
326
  */
337
- async function streamedAnswer<TOutput extends StandardSchemaV1>(
338
- output: TOutput,
327
+ async function streamedAnswer<
328
+ TInput extends StandardSchemaV1,
329
+ TOutput extends StandardSchemaV1,
330
+ V extends PromptVars,
331
+ >(
332
+ def: LlmDef<TInput, TOutput, V>,
339
333
  name: string,
340
334
  gateway: Gateway,
341
335
  request: GenerateRequest,
342
336
  sink: LlmSink,
343
337
  span: Span,
338
+ meter: RunMeter,
344
339
  ): Promise<InferOutput<TOutput>> {
345
- const result = await streamOneTurn(gateway, request, sink);
340
+ const { output } = def;
341
+ const result = await meter.call(streamOneTurn(gateway, request, sink));
346
342
  span.setAttributes({ 'llm.attempts': 1, ...answerAttributes(result) });
343
+ await observeCall(def, meter, result, 1, undefined);
347
344
  if (result.stopReason === 'refusal') {
348
345
  throw new LlmRefusedError({
349
346
  prompt: name,
@@ -369,6 +366,32 @@ async function streamedAnswer<TOutput extends StandardSchemaV1>(
369
366
  });
370
367
  }
371
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
+
372
395
  /** `undefined` for "does not fit". Wrapped so a legitimately falsy value is still a hit. */
373
396
  async function accept<TOutput extends StandardSchemaV1>(
374
397
  schema: TOutput,
package/src/models.ts CHANGED
@@ -5,6 +5,7 @@
5
5
 
6
6
  import { assert, finiteCount, renderCauseValue, renderFixLiteral } from '@ultimat3/core';
7
7
  import type { Money } from '@ultimat3/money';
8
+ import { exponentOf } from '@ultimat3/money';
8
9
  import { AiModelUnknownError, AiRequestInvalidError } from './errors';
9
10
 
10
11
  /**
@@ -59,11 +60,18 @@ export interface ModelReasoning {
59
60
  readonly disabledThinking?: 'disabled' | 'between_tools';
60
61
  }
61
62
 
63
+ /** Digits a price — and the cost computed from it — may carry beyond the currency's minor unit. */
64
+ export const PRICE_SCALE_DIGITS = 6;
65
+
62
66
  export interface ModelSpec {
63
67
  readonly id: ModelId;
64
68
  readonly contextWindow: number;
65
69
  readonly maxOutput: number;
66
- /** Cost of one million input tokens, in minor units. */
70
+ /**
71
+ * Cost of one million input tokens: minor units, or — for a price below one minor unit per
72
+ * million — `Money` at a finer `scale`: `{ minor: 75, currency: 'USD', scale: 3 }` is $0.075.
73
+ * The same holds for the three prices below.
74
+ */
67
75
  readonly inputPerMillion: Money;
68
76
  /** Cost of one million output tokens, in minor units. */
69
77
  readonly outputPerMillion: Money;
@@ -82,6 +90,18 @@ export interface ModelSpec {
82
90
  /** Minimum cacheable prefix; a shorter prefix silently does not cache. */
83
91
  readonly cacheMinimumTokens: number;
84
92
  readonly reasoning: ModelReasoning;
93
+ /**
94
+ * The sampling knobs this model TAKES (`./sampling`). Absent is none: a `temperature` or `seed` a
95
+ * request asks for is then not sent, because a reasoning model answers one with a 400. A row for
96
+ * a model that does take them says `{ temperature: true, seed: true }`.
97
+ */
98
+ readonly sampling?: { readonly temperature?: boolean; readonly seed?: boolean };
99
+ /**
100
+ * `false` for a model that rejects a FORCED tool choice (a 400 naming `tool_choice`, which
101
+ * always-thinking models answer). `llm()`'s one `respond` tool is then offered under the
102
+ * endpoint's default, and a prose answer takes the repair turn. Absent is forced, as before.
103
+ */
104
+ readonly forcedToolChoice?: boolean;
85
105
  /**
86
106
  * The non-text blocks a message to this model may carry. Absent is TEXT ONLY, deliberately: an
87
107
  * app's own row for a text-only endpoint must not have an image sent to it, and the refusal
@@ -147,10 +167,34 @@ export function registerModel(spec: ModelSpec): ModelSpec {
147
167
  if (spec.cacheWritePerMillion !== undefined) {
148
168
  finiteCount(SUBJECT, `${spec.id} cacheWritePerMillion.minor`, spec.cacheWritePerMillion.minor);
149
169
  }
170
+ assertPriceScales(spec);
150
171
  registry.set(spec.id, spec);
151
172
  return spec;
152
173
  }
153
174
 
175
+ /**
176
+ * A price may state a `scale` finer than the currency's minor unit — `{ minor: 75, currency:
177
+ * 'USD', scale: 3 }` is $0.075 per million — up to the scale a COST is carried at. Finer than that
178
+ * cannot be priced exactly, so the row is refused at boot instead of rounded at every call.
179
+ */
180
+ function assertPriceScales(spec: ModelSpec): void {
181
+ const prices = {
182
+ inputPerMillion: spec.inputPerMillion,
183
+ outputPerMillion: spec.outputPerMillion,
184
+ cacheReadPerMillion: spec.cacheReadPerMillion,
185
+ cacheWritePerMillion: spec.cacheWritePerMillion,
186
+ };
187
+ for (const [field, price] of Object.entries(prices)) {
188
+ if (price?.scale === undefined) continue;
189
+ const finest = exponentOf(price.currency) + PRICE_SCALE_DIGITS;
190
+ assert(
191
+ Number.isInteger(price.scale) && price.scale >= 0 && price.scale <= finest,
192
+ `${SUBJECT} row "${spec.id}" states ${field} at scale ${renderCauseValue(price.scale)}; a price in ${renderCauseValue(price.currency)} may be stated at scale 0 to ${finest}`,
193
+ `registerModel({ ...spec, ${field}: { minor, currency: ${renderFixLiteral(price.currency, '<currency>')}, scale: ${finest} } }) # at most ${PRICE_SCALE_DIGITS} digits finer than the minor unit`,
194
+ );
195
+ }
196
+ }
197
+
154
198
  /**
155
199
  * Every price on a row in ONE currency. `costOf` sums them into a single `Money` stamped with the
156
200
  * input price's currency, so a cache rate in another one would be added as if it were the same.
@@ -9,7 +9,9 @@ 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';
14
+ import { samplingBody } from './sampling';
13
15
 
14
16
  export interface ChatCompletionBodyInput {
15
17
  readonly request: GenerateRequest;
@@ -18,12 +20,14 @@ export interface ChatCompletionBodyInput {
18
20
  /** The provider's `name`, so a content refusal names the endpoint that cannot take the block. */
19
21
  readonly provider: string;
20
22
  readonly stream: boolean;
23
+ /** The endpoint, for `PROMPT_CACHE_RULES` — which host this is decides what a cache hint sends. */
24
+ readonly baseUrl?: string | undefined;
21
25
  }
22
26
 
23
27
  /**
24
28
  * The body. What is deliberately absent is as load-bearing as what is present:
25
- * - no `temperature` / `top_p` / `presence_penalty`. Steering is the prompt's job, and a sampling
26
- * knob on a reasoning model in this family is a 400.
29
+ * - no `top_p` / `presence_penalty`, and `temperature` / `seed` only for a model whose row takes
30
+ * them (`ModelSpec.sampling`): a sampling knob on a reasoning model in this family is a 400.
27
31
  * - no `response_format`. The output schema is projected into the `respond` tool by `llm()`, and
28
32
  * that projection is the framework's ONE structured-output path — see the README.
29
33
  * - `max_completion_tokens`, never the deprecated `max_tokens`, which current reasoning models
@@ -33,20 +37,30 @@ export function chatCompletionBody(input: ChatCompletionBodyInput): Record<strin
33
37
  const { request, model, stream, provider } = input;
34
38
  const spec = modelSpec(model);
35
39
  assertMediaContent(request.messages, { provider, model });
40
+ const hint = request.promptCache;
41
+ const rule = promptCacheRule('openai', input.baseUrl, model);
42
+ const marked = hint !== undefined && rule.marks === 'parts';
36
43
  const body: Record<string, unknown> = {
37
44
  model,
38
- messages: toOpenAiMessages(request.system, request.messages, { provider, model }),
45
+ messages: toOpenAiMessages(request.system, request.messages, { provider, model }, marked),
39
46
  max_completion_tokens: Math.min(request.maxTokens, spec.maxOutput),
40
47
  ...reasoningFields(model, request.effort, request.thinking),
48
+ ...samplingBody('openai', model, request),
41
49
  };
42
50
  if (request.tools !== undefined && request.tools.length > 0) {
43
51
  body['tools'] = toOpenAiTools(request.tools);
44
- const choice = toolChoiceFor(request.tools);
52
+ // Forced unless the row says this model rejects a forced choice (`forcedToolChoice: false`).
53
+ const choice = spec.forcedToolChoice === false ? undefined : toolChoiceFor(request.tools);
45
54
  if (choice !== undefined) body['tool_choice'] = choice;
46
55
  }
47
56
  if (request.stopSequences !== undefined && request.stopSequences.length > 0) {
48
57
  body['stop'] = request.stopSequences;
49
58
  }
59
+ // OpenAI's own grouping key. OpenRouter's session rides a header instead (`sessionHeaders`), so
60
+ // the body never carries two spellings of one key.
61
+ if (hint?.session !== undefined && rule.session === 'prompt_cache_key') {
62
+ body['prompt_cache_key'] = cacheKey(hint.session);
63
+ }
50
64
  if (stream) {
51
65
  body['stream'] = true;
52
66
  // Without this the final chunk carries no `usage` and the budget reconciles against nothing —
@@ -57,6 +71,44 @@ export function chatCompletionBody(input: ChatCompletionBodyInput): Record<strin
57
71
  return body;
58
72
  }
59
73
 
74
+ /** The headers a cache hint adds for this endpoint and model: OpenRouter's stickiness key, or none. */
75
+ export function sessionHeaders(
76
+ request: GenerateRequest,
77
+ model: ModelId,
78
+ baseUrl: string | undefined,
79
+ ): Readonly<Record<string, string>> {
80
+ const session = request.promptCache?.session;
81
+ if (session === undefined) return {};
82
+ if (promptCacheRule('openai', baseUrl, model).session !== 'x-session-id') return {};
83
+ return { 'x-session-id': sessionKey(session) };
84
+ }
85
+
86
+ /**
87
+ * OpenAI's grouping key. Never cut — a truncated key is a different key two tenants can share —
88
+ * and never refused for length: over `OPENAI_CACHE_KEY_MAX` it is sent as a hash of itself, which
89
+ * groups the same requests. `h:` plus 62 hex characters of its SHA-256.
90
+ */
91
+ function cacheKey(session: string): string {
92
+ if (session === '') return sessionKey(session);
93
+ if (session.length <= OPENAI_CACHE_KEY_MAX) return session;
94
+ const digest = new Bun.CryptoHasher('sha256').update(session).digest('hex');
95
+ return `h:${digest.slice(0, OPENAI_CACHE_KEY_MAX - 2)}`;
96
+ }
97
+
98
+ /**
99
+ * OpenRouter's header. Refused, never cut: a truncated key is a DIFFERENT key that two tenants can
100
+ * share, and a control character in a header value is a request the runtime refuses with no code.
101
+ */
102
+ function sessionKey(session: string): string {
103
+ if (session !== '' && session.length <= SESSION_KEY_MAX && !/[^\x20-\x7e]/.test(session)) {
104
+ return session;
105
+ }
106
+ throw new AiRequestInvalidError({
107
+ 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`,
108
+ 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`,
109
+ });
110
+ }
111
+
60
112
  /**
61
113
  * The reasoning half, shaped for one model. Refused LOCALLY when the model's spec says the endpoint
62
114
  * has no such control, for the reason models.ts states: a round trip to learn a rule the registry
@@ -8,6 +8,8 @@
8
8
  import type { AiMediaBlock } from './content-blocks';
9
9
  import { isMediaBlock } from './content-blocks';
10
10
  import { AiContentUnsupportedError } from './content-errors';
11
+ import type { SystemPrompt } from './prompt-cache';
12
+ import { CACHE_MARK, systemParts, systemText } from './prompt-cache';
11
13
  import type { AiContentBlock, AiMessage } from './provider';
12
14
  import type { JsonSchema, LlmTool } from './tools';
13
15
 
@@ -20,7 +22,12 @@ export interface OpenAiToolCall {
20
22
 
21
23
  /** A user message's parts, when it carries more than prose. Field names are the format's own. */
22
24
  export type OpenAiContentPart =
23
- | { readonly type: 'text'; readonly text: string }
25
+ | {
26
+ readonly type: 'text';
27
+ readonly text: string;
28
+ /** A prompt-cache breakpoint — sent ONLY where `PROMPT_CACHE_RULES` says `parts`. */
29
+ readonly cache_control?: typeof CACHE_MARK;
30
+ }
24
31
  | { readonly type: 'image_url'; readonly image_url: { readonly url: string } }
25
32
  | {
26
33
  readonly type: 'file';
@@ -28,14 +35,18 @@ export type OpenAiContentPart =
28
35
  };
29
36
 
30
37
  export type OpenAiMessage =
31
- | { readonly role: 'system'; readonly content: string }
38
+ | { readonly role: 'system'; readonly content: string | readonly OpenAiContentPart[] }
32
39
  | { readonly role: 'user'; readonly content: string | readonly OpenAiContentPart[] }
33
40
  | {
34
41
  readonly role: 'assistant';
35
42
  readonly content?: string | undefined;
36
43
  readonly tool_calls?: readonly OpenAiToolCall[] | undefined;
37
44
  }
38
- | { readonly role: 'tool'; readonly tool_call_id: string; readonly content: string };
45
+ | {
46
+ readonly role: 'tool';
47
+ readonly tool_call_id: string;
48
+ readonly content: string | readonly OpenAiContentPart[];
49
+ };
39
50
 
40
51
  export interface OpenAiFunctionTool {
41
52
  readonly type: 'function';
@@ -68,12 +79,17 @@ export interface OpenAiToolChoice {
68
79
  * speaking this format — vLLM, Ollama, LiteLLM, Together — knows only `system`. OpenAI accepts it.
69
80
  */
70
81
  export function toOpenAiMessages(
71
- system: string | undefined,
82
+ system: SystemPrompt | undefined,
72
83
  messages: readonly AiMessage[],
73
84
  target: MediaTarget,
85
+ /** Whether this endpoint and model take explicit `cache_control` parts (`./prompt-cache`). */
86
+ marked = false,
74
87
  ): readonly OpenAiMessage[] {
75
88
  const out: OpenAiMessage[] = [];
76
- if (system !== undefined && system !== '') out.push({ role: 'system', content: system });
89
+ // One string, stable half first, wherever nothing is marked: every server in the family takes
90
+ // it, and an implicit cache is a prefix match.
91
+ const text = systemText(system);
92
+ if (text !== '') out.push({ role: 'system', content: marked ? systemPartsOf(system) : text });
77
93
  messages.forEach((message, m) => {
78
94
  if (typeof message.content === 'string') {
79
95
  out.push({ role: message.role, content: message.content });
@@ -84,9 +100,45 @@ export function toOpenAiMessages(
84
100
  out.push(...userTurn(message.content, m, target));
85
101
  }
86
102
  });
103
+ if (marked) markTail(out);
87
104
  return out;
88
105
  }
89
106
 
107
+ /** The system message as parts: the stable one marked, the per-run one after it and unmarked. */
108
+ function systemPartsOf(system: SystemPrompt | undefined): readonly OpenAiContentPart[] {
109
+ const { stable, run } = systemParts(system);
110
+ const parts: OpenAiContentPart[] = [];
111
+ // A whitespace-only part is sent as it stands and never marked.
112
+ if (stable !== '') {
113
+ parts.push({
114
+ type: 'text',
115
+ text: stable,
116
+ ...(stable.trim() === '' ? {} : { cache_control: CACHE_MARK }),
117
+ });
118
+ }
119
+ if (run !== '') parts.push({ type: 'text', text: run });
120
+ return parts;
121
+ }
122
+
123
+ /**
124
+ * Mark the LAST message's last part — the breakpoint the next turn reads back. A `user` or `tool`
125
+ * message only: those are the roles whose content this wire takes as parts, and a transcript sent
126
+ * to a model always ends on one of them.
127
+ */
128
+ function markTail(out: OpenAiMessage[]): void {
129
+ const tail = out.at(-1);
130
+ if (tail === undefined || (tail.role !== 'user' && tail.role !== 'tool')) return;
131
+ const parts: readonly OpenAiContentPart[] =
132
+ typeof tail.content === 'string' ? [{ type: 'text', text: tail.content }] : tail.content;
133
+ const last = parts.at(-1);
134
+ // Only a text part carries the mark here, and never an empty or whitespace-only one.
135
+ if (last === undefined || last.type !== 'text' || last.text.trim() === '') return;
136
+ out[out.length - 1] = {
137
+ ...tail,
138
+ content: [...parts.slice(0, -1), { ...last, cache_control: CACHE_MARK }],
139
+ };
140
+ }
141
+
90
142
  /** Who a media refusal names: this provider, and the model the request is for. */
91
143
  export interface MediaTarget {
92
144
  readonly provider: string;