@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/CLAUDE.md +137 -137
- package/README.md +95 -4
- package/package.json +11 -11
- package/src/agent-continue.ts +434 -0
- package/src/agent-declared.ts +157 -0
- package/src/agent-def.ts +134 -0
- package/src/agent-errors.ts +69 -0
- package/src/agent-guards.ts +114 -0
- package/src/agent-system.ts +37 -0
- package/src/agent-turn.ts +54 -0
- package/src/agent-usage.ts +63 -0
- package/src/agent.ts +190 -250
- package/src/answer-errors.ts +88 -0
- package/src/billed.ts +87 -0
- package/src/budget.ts +40 -12
- package/src/cost.ts +146 -0
- package/src/echo-provider.ts +3 -1
- package/src/errors-retry.ts +14 -0
- package/src/errors.ts +26 -19
- package/src/failure-disclosure.ts +37 -1
- package/src/gateway.ts +62 -9
- package/src/in-process-tool.ts +77 -0
- package/src/index.ts +47 -3
- package/src/llm-def.ts +97 -0
- package/src/llm.ts +89 -66
- package/src/models.ts +45 -1
- package/src/openai-body.ts +56 -4
- package/src/openai-messages.ts +57 -5
- package/src/openai-provider.ts +26 -5
- package/src/openai-wire.ts +184 -69
- package/src/prompt-cache-blocks.ts +114 -0
- package/src/prompt-cache.ts +189 -0
- package/src/provider.ts +71 -45
- package/src/run-meter.ts +147 -0
- package/src/sampling.ts +86 -0
- package/src/tool-arguments.ts +88 -0
- package/src/tools.ts +51 -6
- package/src/wire.ts +15 -14
package/src/llm.ts
CHANGED
|
@@ -21,36 +21,36 @@
|
|
|
21
21
|
* same rules.
|
|
22
22
|
*/
|
|
23
23
|
|
|
24
|
-
import type { Action,
|
|
24
|
+
import type { Action, InvokeOptions } from '@ultimat3/action';
|
|
25
25
|
import { action } from '@ultimat3/action';
|
|
26
26
|
import type { Ctx, Span, SpanAttributes } from '@ultimat3/core';
|
|
27
27
|
import { finiteCount, withSpan } from '@ultimat3/core';
|
|
28
|
-
import type { Money } from '@ultimat3/money';
|
|
29
28
|
import type { InferInput, InferOutput, StandardSchemaV1 } from '@ultimat3/schema';
|
|
30
29
|
import { formatIssues, validateAsync } from '@ultimat3/schema';
|
|
30
|
+
import { runTotal } from './agent-usage';
|
|
31
|
+
import { LlmTruncatedError } from './answer-errors';
|
|
31
32
|
import type { BudgetLimits } from './budget';
|
|
32
33
|
import { budgetKeysFor, currentBudget, screenedMoney, withBudget } from './budget';
|
|
33
|
-
import {
|
|
34
|
-
LlmOutputInvalidError,
|
|
35
|
-
LlmRefusedError,
|
|
36
|
-
LlmStreamInvalidError,
|
|
37
|
-
LlmTruncatedError,
|
|
38
|
-
} from './errors';
|
|
34
|
+
import { LlmOutputInvalidError, LlmRefusedError, LlmStreamInvalidError } from './errors';
|
|
39
35
|
import type { Gateway } from './gateway';
|
|
40
|
-
import type { LlmCache } from './llm-cache';
|
|
41
36
|
import { openCache } from './llm-cache';
|
|
37
|
+
import type { LlmBudget, LlmDef } from './llm-def';
|
|
42
38
|
import type { LlmSink, LlmStreamChunk } from './llm-stream';
|
|
43
39
|
import { currentLlmSink, llmStream, streamOneTurn, withLlmSink } from './llm-stream';
|
|
44
40
|
import { modelForCall } from './model-resolve';
|
|
45
|
-
import type { ModelId } from './models';
|
|
46
41
|
import { moreCapableThan } from './models';
|
|
47
|
-
import type {
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
296
|
-
|
|
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({
|
|
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<
|
|
338
|
-
|
|
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
|
|
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
|
-
/**
|
|
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.
|
package/src/openai-body.ts
CHANGED
|
@@ -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 `
|
|
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
|
-
|
|
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
|
package/src/openai-messages.ts
CHANGED
|
@@ -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
|
-
| {
|
|
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
|
-
| {
|
|
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:
|
|
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
|
-
|
|
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;
|