@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.
- package/CLAUDE.md +57 -53
- package/README.md +75 -0
- package/package.json +11 -11
- package/src/agent-def.ts +111 -0
- package/src/agent-errors.ts +50 -0
- package/src/agent-guards.ts +114 -0
- package/src/agent-system.ts +37 -0
- package/src/agent-usage.ts +27 -0
- package/src/agent.ts +65 -89
- package/src/budget.ts +3 -2
- package/src/errors-retry.ts +6 -0
- package/src/errors.ts +4 -0
- package/src/gateway.ts +9 -2
- package/src/index.ts +19 -1
- package/src/llm.ts +11 -0
- package/src/openai-body.ts +50 -1
- package/src/openai-messages.ts +57 -5
- package/src/openai-provider.ts +15 -3
- package/src/openai-wire.ts +16 -7
- package/src/prompt-cache-blocks.ts +114 -0
- package/src/prompt-cache.ts +189 -0
- package/src/provider.ts +39 -11
|
@@ -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
|
|
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 {
|
|
48
|
-
import
|
|
49
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
356
|
-
|
|
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
|
-
|
|
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
|
|
package/src/errors-retry.ts
CHANGED
|
@@ -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 }),
|
package/src/openai-body.ts
CHANGED
|
@@ -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
|