@ultimat3/ai 29.4.0 → 30.0.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.
Files changed (47) hide show
  1. package/CLAUDE.md +63 -90
  2. package/README.md +125 -8
  3. package/package.json +23 -11
  4. package/src/agent-checkpoint.ts +44 -0
  5. package/src/agent-compact-def.ts +11 -0
  6. package/src/agent-compact-measure.ts +38 -0
  7. package/src/agent-compact-request.ts +100 -0
  8. package/src/agent-compact-text.ts +95 -0
  9. package/src/agent-compact.ts +75 -50
  10. package/src/agent-continue.ts +15 -3
  11. package/src/agent-def.ts +9 -0
  12. package/src/agent-errors.ts +12 -2
  13. package/src/agent-facts.ts +8 -0
  14. package/src/agent-send.ts +82 -0
  15. package/src/agent-stream.ts +76 -0
  16. package/src/agent.ts +71 -21
  17. package/src/answer-errors.ts +1 -1
  18. package/src/anthropic-provider.ts +291 -0
  19. package/src/content-errors.ts +1 -1
  20. package/src/context-overflow.ts +86 -0
  21. package/src/error-codes.ts +95 -0
  22. package/src/errors-retry.ts +8 -4
  23. package/src/errors.ts +6 -87
  24. package/src/eval-agent.ts +100 -0
  25. package/src/eval-errors.ts +31 -5
  26. package/src/evals.ts +125 -45
  27. package/src/gateway-providers.ts +215 -0
  28. package/src/gateway.ts +63 -153
  29. package/src/hive-errors.ts +1 -1
  30. package/src/in-process-tool.ts +20 -11
  31. package/src/index.ts +31 -7
  32. package/src/llm.ts +39 -8
  33. package/src/model-resolve.ts +7 -1
  34. package/src/openai-body.ts +12 -5
  35. package/src/openai-provider.ts +28 -8
  36. package/src/openai-wire.ts +11 -7
  37. package/src/prompt-cache-rows.ts +82 -0
  38. package/src/prompt-cache.ts +24 -2
  39. package/src/provider.ts +15 -274
  40. package/src/remote-embedder.ts +38 -11
  41. package/src/run-meter.ts +40 -2
  42. package/src/runtime.ts +38 -3
  43. package/src/scorers.ts +35 -1
  44. package/src/scripted-provider.ts +146 -0
  45. package/src/tools-gate.ts +46 -0
  46. package/src/tools.ts +101 -27
  47. package/src/wire.ts +12 -4
package/src/gateway.ts CHANGED
@@ -4,17 +4,8 @@
4
4
  // budgeted, and cost-accounted in integer minor units. Everything an app does with a model
5
5
  // goes through here, so budgets and accounting cannot be bypassed by a stray fetch.
6
6
 
7
- import type { Random } from '@ultimat3/core';
8
- import {
9
- backoffDelay,
10
- fingerprint,
11
- finiteCount,
12
- isRetryableStatus,
13
- isUltimateError,
14
- logger,
15
- renderThrowable,
16
- stringField,
17
- } from '@ultimat3/core';
7
+ import type { Breaker, Random } from '@ultimat3/core';
8
+ import { fingerprint, finiteCount, logger, renderThrowable } from '@ultimat3/core';
18
9
  import type { Money } from '@ultimat3/money';
19
10
  import { billedOf } from './billed';
20
11
  import type { BudgetKeys, BudgetLimits, BudgetReservation, BudgetStore } from './budget';
@@ -25,11 +16,18 @@ import {
25
16
  estimateSpend,
26
17
  withBudget,
27
18
  } from './budget';
28
- import { AiProviderUnavailableError } from './errors';
19
+ import type { GatewayBreakerOption } from './gateway-providers';
20
+ import {
21
+ admittedProvider,
22
+ attemptProviders,
23
+ providerBreakers,
24
+ streamVerdict,
25
+ } from './gateway-providers';
29
26
  import { resolveModel } from './model-resolve';
30
27
  import type { ModelId } from './models';
31
28
  import type { GenerateRequest, GenerateResult, Provider, StreamChunk } from './provider';
32
29
  import type { SamplingWire } from './sampling';
30
+ import { ZERO_USAGE } from './wire';
33
31
 
34
32
  /**
35
33
  * Response cache. Structurally satisfied by `@ultimat3/cache`'s memo/LRU tiers; declared as
@@ -61,8 +59,8 @@ export const DEFAULT_GATEWAY_RETRY: RetryPolicy = {
61
59
  maxDelayMs: 8_000,
62
60
  };
63
61
 
64
- /** The one code `attempt` may collect into. Every other coded refusal is the caller's answer. */
65
- const PROVIDER_UNAVAILABLE = 'X_AI_PROVIDER_UNAVAILABLE';
62
+ export type { GatewayBreakerOption } from './gateway-providers';
63
+ export { backoffMs, DEFAULT_GATEWAY_BREAKER, isRetryable } from './gateway-providers';
66
64
 
67
65
  export interface ProviderGatewayInput {
68
66
  /** Tried in order for a given model. First provider that lists the model wins. */
@@ -81,6 +79,12 @@ export interface ProviderGatewayInput {
81
79
  * green. Production never passes one.
82
80
  */
83
81
  readonly random?: Random | undefined;
82
+ /**
83
+ * One breaker per provider, on by default (`{ failures: 5, cooldownMs: 30_000 }`): a provider
84
+ * whose retries ran out five passes running is skipped — no call, no sleep — until one probe
85
+ * after the cooldown answers. Per process. `(provider) => breaker({ … })` to choose your own, `'off'` declines.
86
+ */
87
+ readonly breaker?: GatewayBreakerOption | undefined;
84
88
  }
85
89
 
86
90
  export interface Gateway {
@@ -124,6 +128,7 @@ class GatewayImpl implements Gateway {
124
128
  private readonly sleep: (ms: number) => Promise<void>;
125
129
  /** Left `undefined` rather than defaulted, so `backoffDelay` owns the one fallback to `Math.random`. */
126
130
  private readonly random: Random | undefined;
131
+ private readonly gateOf: (provider: Provider) => Breaker | undefined;
127
132
 
128
133
  get defaultModel(): ModelId | undefined {
129
134
  return this.config.defaultModel;
@@ -146,6 +151,7 @@ class GatewayImpl implements Gateway {
146
151
  if (config.budget !== undefined) assertFiniteLimits(config.budget, 'gateway', 'budget.');
147
152
  this.sleep = config.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
148
153
  this.random = config.random;
154
+ this.gateOf = providerBreakers(config.breaker);
149
155
  }
150
156
 
151
157
  /**
@@ -179,8 +185,9 @@ class GatewayImpl implements Gateway {
179
185
  const cacheKey = promptCacheKey(resolved);
180
186
  const cached = await this.config.cache?.get(cacheKey);
181
187
  if (cached !== undefined) {
182
- // A cache hit costs nothing, so it is deliberately NOT debited from the budget.
183
- return JSON.parse(cached) as GenerateResult;
188
+ // A cache hit costs nothing, so it is deliberately NOT debited from the budget, and says so:
189
+ // carrying the stored figures, a meter summing the run billed it a second time.
190
+ return freeOf(JSON.parse(cached) as GenerateResult);
184
191
  }
185
192
 
186
193
  // Reserve against the ESTIMATE before spending anything — tokens AND money, since a
@@ -196,7 +203,15 @@ class GatewayImpl implements Gateway {
196
203
 
197
204
  let result: GenerateResult;
198
205
  try {
199
- result = await this.attempt(model, (provider) => provider.generate(resolved));
206
+ result = await attemptProviders({
207
+ model,
208
+ candidates: this.config.providers.filter((p) => p.models.includes(model)),
209
+ retry: this.retry,
210
+ sleep: this.sleep,
211
+ random: this.random,
212
+ gateOf: this.gateOf,
213
+ call: (provider) => provider.generate(resolved),
214
+ });
200
215
  } catch (error) {
201
216
  // A call that never landed must not go on holding its reservation — and one that DID land
202
217
  // and still failed (`billedOf`: the provider answered with something unusable) is recorded
@@ -251,15 +266,28 @@ class GatewayImpl implements Gateway {
251
266
  // single provider serving this model and that call stands or throws; `attempt`'s backoff and
252
267
  // its fallback across providers belong to `generate` alone. A caller that wants either uses
253
268
  // `generate`, or reconnects itself and knows what it has already shown.
254
- const provider = this.providerFor(model);
269
+ const { provider, ticket } = admittedProvider(model, this.config.providers, this.gateOf);
270
+ const gate = this.gateOf(provider);
271
+ let answered = false;
255
272
 
256
273
  const ledger = currentBudget() ?? this.callLedger({});
257
- const reservation = await ledger?.reserve(
258
- estimateSpend(resolved, this.marksPromptCache(model)),
259
- );
274
+ let reservation: BudgetReservation | undefined;
275
+ try {
276
+ reservation = await ledger?.reserve(estimateSpend(resolved, this.marksPromptCache(model)));
277
+ } catch (error) {
278
+ // A refused reservation sent nothing: the half-open probe `admittedProvider` took goes back,
279
+ // or the next healthy call would wait out a whole extra cooldown.
280
+ gate?.released(ticket);
281
+ throw error;
282
+ }
260
283
  let settled = false;
261
284
  try {
262
285
  for await (const chunk of provider.stream(resolved)) {
286
+ // The first chunk is the provider's verdict for its breaker: it answered.
287
+ if (!answered) {
288
+ answered = true;
289
+ streamVerdict(gate, ticket, undefined);
290
+ }
263
291
  if (chunk.type !== 'done') {
264
292
  yield chunk;
265
293
  continue;
@@ -272,6 +300,10 @@ class GatewayImpl implements Gateway {
272
300
  yield { type: 'done', result: { ...chunk.result, provider: provider.name } };
273
301
  }
274
302
  } catch (error) {
303
+ if (!answered) {
304
+ answered = true;
305
+ streamVerdict(gate, ticket, error);
306
+ }
275
307
  // Thrown after the provider reported usage: the bill is recorded, exactly as `generate` does.
276
308
  // Only when nothing settled yet — a provider that throws AFTER its `done` chunk was already
277
309
  // recorded, and releasing again would credit the ledger a second time.
@@ -284,6 +316,8 @@ class GatewayImpl implements Gateway {
284
316
  // A stream its consumer abandoned never reached `done` — so nothing reconciled the
285
317
  // reservation and it would hold the ceiling for the rest of the window.
286
318
  if (!settled) await ledger?.release(reservation);
319
+ // Abandoned before a chunk: the probe goes back, counting nothing.
320
+ if (!answered) gate?.released(ticket);
287
321
  }
288
322
  }
289
323
 
@@ -299,73 +333,6 @@ class GatewayImpl implements Gateway {
299
333
  (p) => p.models.includes(model) && p.marksPromptCache?.(model) === true,
300
334
  );
301
335
  }
302
-
303
- private providerFor(model: ModelId): Provider {
304
- const provider = this.config.providers.find((p) => p.models.includes(model));
305
- if (provider === undefined) {
306
- throw new AiProviderUnavailableError({
307
- model,
308
- attempts: this.config.providers.map((p) => `${p.name}: does not serve ${model}`),
309
- unserved: true,
310
- });
311
- }
312
- return provider;
313
- }
314
-
315
- /**
316
- * Try every provider that serves `model`, retrying each on a retryable failure with
317
- * exponential backoff plus full jitter (jitter matters: synchronised retries from N
318
- * workers reproduce the rate limit they are backing off from).
319
- *
320
- * Fallback here is across PROVIDERS serving one model, never across models — a silent model
321
- * swap changes what answered, what it cost and which eval baseline the answer belongs to. The
322
- * provider that did answer is stamped onto the result, so the fallback that DOES exist reaches
323
- * the span instead of being invisible.
324
- */
325
- private async attempt(
326
- model: ModelId,
327
- call: (provider: Provider) => Promise<GenerateResult>,
328
- ): Promise<GenerateResult> {
329
- const candidates = this.config.providers.filter((p) => p.models.includes(model));
330
- const failures: string[] = [];
331
- if (candidates.length === 0) {
332
- throw new AiProviderUnavailableError({
333
- model,
334
- attempts: [`no provider serves ${model}`],
335
- unserved: true,
336
- });
337
- }
338
-
339
- for (const provider of candidates) {
340
- for (let attempt = 1; attempt <= this.retry.attempts; attempt += 1) {
341
- try {
342
- return { ...(await call(provider)), provider: provider.name };
343
- } catch (error) {
344
- // A coded refusal that is NOT `X_AI_PROVIDER_UNAVAILABLE` reaches the caller verbatim.
345
- // Those are raised locally, before the socket opens — a missing credential, a control
346
- // the model does not accept — so the same rejection is waiting on every provider and
347
- // every attempt, which is the reason a 400 is not retried three lines below. Collecting
348
- // one into `X_AI_PROVIDER_UNAVAILABLE` discards its runnable `fix:` and answers the same
349
- // failure a different way from `stream`, which does not route through here at all.
350
- // A transport failure keeps the old path: `AiTransportError` IS
351
- // `X_AI_PROVIDER_UNAVAILABLE`, so "provider one 503'd, provider two timed out" still
352
- // collects across the candidates. `stringField` rather than `error.code`, because the
353
- // value came from an app's `Provider` and a property read on it can trap.
354
- if (isUltimateError(error) && stringField(error, 'code') !== PROVIDER_UNAVAILABLE) {
355
- throw error;
356
- }
357
- // `renderThrowable`, never `error.message` or `String(error)`: this line becomes the
358
- // `cause` of `X_AI_PROVIDER_UNAVAILABLE`, and a renderer that throws replaces the coded
359
- // refusal with a `TypeError` nothing downstream can catch by code. It bounds the text
360
- // too — a provider's 1MB body is not a cause.
361
- failures.push(`${provider.name}#${attempt}: ${renderThrowable(error)}`);
362
- if (!isRetryable(error) || attempt === this.retry.attempts) break;
363
- await this.sleep(backoffMs(this.retry, attempt, this.random));
364
- }
365
- }
366
- }
367
- throw new AiProviderUnavailableError({ model, attempts: failures });
368
- }
369
336
  }
370
337
 
371
338
  /** A failed call's reservation: reconciled against its bill when the failure carries one, else given back. */
@@ -379,6 +346,14 @@ async function settleFailure(
379
346
  await ledger?.record(billed.usage, billed.cost, reservation);
380
347
  }
381
348
 
349
+ /** The stored answer as a hit delivers it: no token spent, no money, in the currency it named. */
350
+ const freeOf = (stored: GenerateResult): GenerateResult => ({
351
+ ...stored,
352
+ usage: ZERO_USAGE,
353
+ cost: { ...stored.cost, minor: 0 },
354
+ ...(stored.costExact === undefined ? {} : { costExact: { ...stored.costExact, minor: 0 } }),
355
+ });
356
+
382
357
  /**
383
358
  * The request's completion ceiling, screened at the one seam every model call in an app passes
384
359
  * through — `llm()`, `agent()`, an eval, a judge and a hand-built `generate()` alike.
@@ -394,71 +369,6 @@ function ceilingOf(request: GenerateRequest): number {
394
369
  return finiteCount('the AI gateway', 'maxTokens', request.maxTokens, 1);
395
370
  }
396
371
 
397
- /**
398
- * Full jitter: a uniform pick from [0, exponential], capped BEFORE the roll.
399
- *
400
- * The arithmetic is core's `backoffDelay` — this is the mapping from the gateway's own field names
401
- * onto it, and nothing else. Two things came with the delegation and neither is cosmetic: the
402
- * result is ROUNDED where this floored it (a shift of at most 1ms, and the same rounding
403
- * `@ultimat3/jobs` and `@ultimat3/realtime` already use), and a policy carrying a `NaN` — which is
404
- * what `Number(process.env.…)` answers for an unset variable — is REFUSED rather than clamped.
405
- * This paragraph said "waits 0" until 2026-08-26, which was the safe answer to the wrong question
406
- * and had already stopped being true: a schedule of zeroes still spins, it just spins on purpose,
407
- * so core's `backoffDelay` refuses a non-finite bound before it clamps and this gateway inherits
408
- * the refusal with the curve. `gateway-backoff.test.ts` pins both halves — the refusal, and the
409
- * negative base that IS still clamped to zero.
410
- */
411
- export function backoffMs(policy: RetryPolicy, attempt: number, random?: Random): number {
412
- return backoffDelay({
413
- attempt,
414
- base: policy.baseDelayMs,
415
- max: policy.maxDelayMs,
416
- curve: 'exponential',
417
- jitter: 'full',
418
- random,
419
- });
420
- }
421
-
422
- /**
423
- * Retryable = the request was well formed and the provider was momentarily unable. A 400 is
424
- * never retried: the same body produces the same rejection and only burns the budget.
425
- *
426
- * The status half is core's `isRetryableStatus`, which is WIDER than the `429 || >= 500` this
427
- * gateway shipped: 408, 409 and 425 join it. Each is transient by construction — the server gave up
428
- * waiting for a body it never fully read, a concurrent writer won the round, the handshake was not
429
- * finished — so not retrying them was a gap rather than a policy, and one narrower table in one
430
- * package was how it stayed invisible. The `code` branch has no equivalent in core and stays here:
431
- * that table is HTTP status only, and a socket that timed out never produced one.
432
- */
433
- /**
434
- * A transport that never produced a response. Node spells these `ETIMEDOUT`/`ECONNRESET`; Bun's
435
- * `fetch` spells a refused, dropped or unopenable connection `ConnectionRefused`,
436
- * `ConnectionClosed` and `FailedToOpenSocket` — which this list did not know, so on the one runtime
437
- * the framework runs on a provider mid-restart was never retried.
438
- */
439
- const RETRYABLE_TRANSPORT_CODES: ReadonlySet<string> = new Set([
440
- 'ETIMEDOUT',
441
- 'ECONNRESET',
442
- 'ConnectionRefused',
443
- 'ConnectionClosed',
444
- 'FailedToOpenSocket',
445
- ]);
446
-
447
- export function isRetryable(error: unknown): boolean {
448
- if (typeof error !== 'object' || error === null) return false;
449
- // A `Provider` is the APP's object, so the value it rejected with is one the framework did not
450
- // build: `e.status` is a getter call and, on a `Proxy`, a trap. A value that fights being read
451
- // cannot be SHOWN to be retryable, and this runs inside the catch block that has nothing left
452
- // to answer with — so it fails closed rather than raising.
453
- try {
454
- const e = error as { status?: unknown; code?: unknown };
455
- if (typeof e.status === 'number') return isRetryableStatus(e.status);
456
- return typeof e.code === 'string' && RETRYABLE_TRANSPORT_CODES.has(e.code);
457
- } catch {
458
- return false;
459
- }
460
- }
461
-
462
372
  /**
463
373
  * Cache key. Every field that changes the answer is in it — a key that ignores `effort` or
464
374
  * `system` would serve one prompt's answer for another — and each tool WHOLE: every `llm()` tool
@@ -1,6 +1,6 @@
1
1
  // The X_HIVE_* codes, apart from ./errors only because one file has one job and that catalogue is
2
2
  // already at its ceiling — the same split `eval-errors.ts` made. The codes, their titles and the
3
- // single `registerErrorCodes` call stay in ./errors: one owner, one registration, one place a
3
+ // single `registerErrorCodes` call stay in ./error-codes: one owner, one registration, one place a
4
4
  // duplicate can surface.
5
5
 
6
6
  import { UltimateError } from '@ultimat3/core';
@@ -60,18 +60,27 @@ export function inProcessTool<TInput extends StandardSchemaV1>(
60
60
  inputJsonSchema: toWireSchema(def.input),
61
61
  inProcess: true,
62
62
  ...(def.discloseErrors === undefined ? {} : { discloseErrors: def.discloseErrors }),
63
+ parseInput: (raw) => parseToolInput(def, raw),
63
64
  async run({ input, actor }) {
64
- const parsed = await validateAsync(def.input, input);
65
- if (parsed.issues !== undefined) {
66
- // The line only: the structured issues are `@ultimat3/action`'s own shape, for a form.
67
- throw new InputInvalidError(
68
- def.name,
69
- formatIssues(parsed.issues).join('; '),
70
- undefined,
71
- 'tool',
72
- );
73
- }
74
- return def.execute({ input: parsed.value, actor });
65
+ return def.execute({ input: await parseToolInput(def, input), actor });
75
66
  },
76
67
  };
77
68
  }
69
+
70
+ /** The input parse `run` makes, alone — also what an agent eval holds this tool's double to. */
71
+ async function parseToolInput<TInput extends StandardSchemaV1>(
72
+ def: InProcessToolDef<TInput>,
73
+ raw: unknown,
74
+ ): Promise<InferOutput<TInput>> {
75
+ const parsed = await validateAsync(def.input, raw);
76
+ if (parsed.issues !== undefined) {
77
+ // The line only: the structured issues are `@ultimat3/action`'s own shape, for a form.
78
+ throw new InputInvalidError(
79
+ def.name,
80
+ formatIssues(parsed.issues).join('; '),
81
+ undefined,
82
+ 'tool',
83
+ );
84
+ }
85
+ return parsed.value;
86
+ }
package/src/index.ts CHANGED
@@ -8,6 +8,7 @@ export { agent } from './agent';
8
8
  export type { AgentCompaction, AgentCompactOption, AgentCompactRule } from './agent-compact-def';
9
9
  export type {
10
10
  AgentAction,
11
+ AgentCheckpoint,
11
12
  AgentContinuation,
12
13
  AgentRun,
13
14
  AgentTranscript,
@@ -81,6 +82,7 @@ export {
81
82
  } from './content-blocks';
82
83
  export type { ContentRefusal } from './content-errors';
83
84
  export { AiContentUnsupportedError } from './content-errors';
85
+ export { AiContextOverflowError } from './context-overflow';
84
86
  export { COST_SCALE_DIGITS, costScale, exactCostOf, roundUpCost } from './cost';
85
87
  export type { EchoProvider, EchoProviderInput } from './echo-provider';
86
88
  export { echoProvider } from './echo-provider';
@@ -93,12 +95,11 @@ export {
93
95
  normalizeVector,
94
96
  wordTokens,
95
97
  } from './embeddings';
96
- export type { AiErrorCode } from './errors';
98
+ export type { AiErrorCode } from './error-codes';
99
+ export { AI_ERROR_CODES, AI_ERROR_TITLES } from './error-codes';
97
100
  export {
98
101
  AgentMaxTurnsError,
99
102
  AgentToolUnexposedError,
100
- AI_ERROR_CODES,
101
- AI_ERROR_TITLES,
102
103
  AiBudgetExceededError,
103
104
  AiGatewayMissingError,
104
105
  AiKeyMissingError,
@@ -133,14 +134,19 @@ export {
133
134
  EvalMissingError,
134
135
  EvalRecordingError,
135
136
  EvalThresholdError,
137
+ EvalToolUndoubledError,
136
138
  } from './eval-errors';
137
139
  export type {
140
+ AgentEvalCase,
141
+ AgentEvalInput,
138
142
  CaseResult,
139
143
  DefineEvalInput,
140
144
  Eval,
141
145
  EvalCase,
142
146
  EvalFact,
143
147
  EvalResult,
148
+ EvaluatedAgent,
149
+ PromptEvalInput,
144
150
  } from './evals';
145
151
  export {
146
152
  baselineFrom,
@@ -150,9 +156,16 @@ export {
150
156
  resetEvals,
151
157
  } from './evals';
152
158
  export type { AiFetch } from './fetch-seam';
153
- export type { Gateway, GatewayCache, ProviderGatewayInput, RetryPolicy } from './gateway';
159
+ export type {
160
+ Gateway,
161
+ GatewayBreakerOption,
162
+ GatewayCache,
163
+ ProviderGatewayInput,
164
+ RetryPolicy,
165
+ } from './gateway';
154
166
  export {
155
167
  backoffMs,
168
+ DEFAULT_GATEWAY_BREAKER,
156
169
  DEFAULT_GATEWAY_RETRY,
157
170
  isRetryable,
158
171
  promptCacheKey,
@@ -165,7 +178,7 @@ export type { HiveMember, HiveMemberError, HiveOutput, HiveResult } from './hive
165
178
  export type { InProcessToolDef } from './in-process-tool';
166
179
  export { inProcessTool } from './in-process-tool';
167
180
  export type { LlmAction, LlmBudget, LlmCall, LlmDef, LlmVarsArgs } from './llm';
168
- export { llm } from './llm';
181
+ export { LlmTextChunk, llm } from './llm';
169
182
  export type { LlmCache, LlmScopeArgs, LlmSemanticCache } from './llm-cache';
170
183
  export type { LlmStreamChunk } from './llm-stream';
171
184
  export type { ModelSource } from './model-resolve';
@@ -220,6 +233,8 @@ export type {
220
233
  PromptCacheHint,
221
234
  PromptCacheOption,
222
235
  PromptCacheRule,
236
+ ProviderCacheRow,
237
+ ProviderPromptCache,
223
238
  SystemParts,
224
239
  SystemPrompt,
225
240
  } from './prompt-cache';
@@ -275,7 +290,8 @@ export {
275
290
  export { assertNoSecrets } from './redaction';
276
291
  export type { RemoteEmbedder, RemoteEmbedderInput } from './remote-embedder';
277
292
  export { remoteEmbedder } from './remote-embedder';
278
- export type { AiRuntimeInput, Redactor } from './runtime';
293
+ export { meteredRun } from './run-meter';
294
+ export type { AiRuntimeInput, Redactor, ToolDouble } from './runtime';
279
295
  export {
280
296
  aiEmbedder,
281
297
  aiGateway,
@@ -284,8 +300,9 @@ export {
284
300
  semanticCacheFor,
285
301
  } from './runtime';
286
302
  export type { Sampling } from './sampling';
287
- export type { Scorer } from './scorers';
303
+ export type { EvalRun, EvalToolCall, Scorer, ScorerInput } from './scorers';
288
304
  export {
305
+ calledTool,
289
306
  contains,
290
307
  exact,
291
308
  jsonSchemaValid,
@@ -293,6 +310,13 @@ export {
293
310
  llmJudge,
294
311
  numericTolerance,
295
312
  } from './scorers';
313
+ export type {
314
+ ScriptedCall,
315
+ ScriptedProvider,
316
+ ScriptedProviderInput,
317
+ ScriptedTurn,
318
+ } from './scripted-provider';
319
+ export { AiScriptExhaustedError, scriptedProvider } from './scripted-provider';
296
320
  export type {
297
321
  AgentTool,
298
322
  JsonSchema,
package/src/llm.ts CHANGED
@@ -21,17 +21,22 @@
21
21
  * same rules.
22
22
  */
23
23
 
24
- import type { Action, InvokeOptions } from '@ultimat3/action';
25
- import { action } from '@ultimat3/action';
24
+ import type { InvokeOptions, StreamAction } from '@ultimat3/action';
25
+ import { action, chunksRead } from '@ultimat3/action';
26
26
  import type { Ctx, Span, SpanAttributes } from '@ultimat3/core';
27
27
  import { finiteCount, withSpan } from '@ultimat3/core';
28
28
  import type { InferInput, InferOutput, StandardSchemaV1 } from '@ultimat3/schema';
29
- import { formatIssues, validateAsync } from '@ultimat3/schema';
29
+ import { formatIssues, t, validateAsync } from '@ultimat3/schema';
30
30
  import { runTotal } from './agent-usage';
31
31
  import { LlmTruncatedError } from './answer-errors';
32
32
  import type { BudgetLimits } from './budget';
33
33
  import { budgetKeysFor, currentBudget, screenedMoney, withBudget } from './budget';
34
- import { LlmOutputInvalidError, LlmRefusedError, LlmStreamInvalidError } from './errors';
34
+ import {
35
+ AiTransportError,
36
+ LlmOutputInvalidError,
37
+ LlmRefusedError,
38
+ LlmStreamInvalidError,
39
+ } from './errors';
35
40
  import type { Gateway } from './gateway';
36
41
  import { openCache } from './llm-cache';
37
42
  import type { LlmBudget, LlmDef } from './llm-def';
@@ -63,13 +68,21 @@ const DEFAULT_MAX_TOKENS = 4_096;
63
68
 
64
69
  export type { LlmBudget, LlmCall, LlmDef, LlmVarsArgs } from './llm-def';
65
70
 
71
+ /**
72
+ * What `llm()`'s route streams to a caller asking for `text/event-stream`: the answer's text, as it
73
+ * arrives. `thinking` never crosses the wire — reasoning is for the server's own `.stream()`
74
+ * consumer to decide about, not for a browser to render by default. The final frame is `done`,
75
+ * the value that satisfied `output`.
76
+ */
77
+ export const LlmTextChunk = t.object({ type: t.literal('text'), text: t.string });
78
+
66
79
  /**
67
80
  * An `action` with one extra way to be called. Every projection an action has, it has — its MCP tool,
68
81
  * `.openapi()`, `.client()`, `.job()`, `.contract()` — plus a transport for the case an action's
69
82
  * single return value cannot serve: text on a screen before the answer is finished.
70
83
  */
71
84
  export interface LlmAction<TInput extends StandardSchemaV1, TOutput extends StandardSchemaV1>
72
- extends Action<TInput, TOutput> {
85
+ extends StreamAction<TInput, TOutput, typeof LlmTextChunk> {
73
86
  /**
74
87
  * The same call, delivered as it arrives. Runs the action's policy, input parse, budget scope,
75
88
  * semantic cache and span exactly as calling it would — the invocation IS an ordinary one,
@@ -106,12 +119,30 @@ export function llm<
106
119
  // a call that cannot answer.
107
120
  finiteCount('llm', 'maxTokens', def.maxTokens ?? DEFAULT_MAX_TOKENS, 1);
108
121
  const sampling = screenSampling('llm', def);
109
- const built = action<TInput, TOutput>({
122
+ const built = action<TInput, TOutput, typeof LlmTextChunk>({
110
123
  input: def.input,
111
124
  output: def.output,
112
125
  policy: def.policy,
113
126
  ...(def.mcp === undefined ? {} : { mcp: def.mcp }),
114
- handle: (args) => generate(def, respond, sampling, args),
127
+ // Declared, so the route answers `text/event-stream` when asked. Drained (MCP, `.job()`, JSON,
128
+ // a direct call) it is the non-streaming call, repair turn included; only a caller READING the
129
+ // chunks gets the one-attempt streamed turn, exactly as server-side `.stream()` does.
130
+ stream: LlmTextChunk,
131
+ async *handle(args) {
132
+ if (!chunksRead()) return await generate(def, respond, sampling, args);
133
+ const chunks = llmStream<InferOutput<TOutput>>((sink) =>
134
+ withLlmSink(sink, () => generate(def, respond, sampling, args)),
135
+ );
136
+ for await (const chunk of chunks) {
137
+ if (chunk.type === 'text') yield { type: 'text' as const, text: chunk.text };
138
+ else if (chunk.type === 'done') return chunk.value;
139
+ }
140
+ // `llmStream` ends with `done` or throws; reaching here is a sink that closed early.
141
+ throw new AiTransportError({
142
+ provider: 'gateway',
143
+ detail: 'the streamed answer ended without its value',
144
+ });
145
+ },
115
146
  });
116
147
  return streamable(built);
117
148
  }
@@ -126,7 +157,7 @@ export function llm<
126
157
  * The original is captured first, or the override would call itself.
127
158
  */
128
159
  function streamable<TInput extends StandardSchemaV1, TOutput extends StandardSchemaV1>(
129
- target: Action<TInput, TOutput>,
160
+ target: StreamAction<TInput, TOutput, typeof LlmTextChunk>,
130
161
  ): LlmAction<TInput, TOutput> {
131
162
  const rename = target.named.bind(target);
132
163
  const self: LlmAction<TInput, TOutput> = Object.assign(target, {
@@ -7,7 +7,13 @@ import type { ModelId } from './models';
7
7
  import { aiGateway } from './runtime';
8
8
 
9
9
  /** Where a model was resolved. Bounded, and the first word of the refusal's cause. */
10
- export type ModelSite = 'llm' | 'agent' | 'gateway' | 'provider' | 'echo-provider';
10
+ export type ModelSite =
11
+ | 'llm'
12
+ | 'agent'
13
+ | 'gateway'
14
+ | 'provider'
15
+ | 'echo-provider'
16
+ | 'scripted-provider';
11
17
 
12
18
  /** The three places, spelled once so the refusal and the docs cannot drift apart. */
13
19
  const PLACES =
@@ -9,10 +9,13 @@ 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 type { PromptCacheRule, ProviderCacheRow } from './prompt-cache';
12
13
  import { OPENAI_CACHE_KEY_MAX, promptCacheRule, SESSION_KEY_MAX } from './prompt-cache';
13
14
  import type { GenerateRequest } from './provider';
14
15
  import { samplingBody } from './sampling';
15
16
 
17
+ type CacheRule = Pick<PromptCacheRule, 'marks' | 'session'>;
18
+
16
19
  export interface ChatCompletionBodyInput {
17
20
  readonly request: GenerateRequest;
18
21
  /** Already resolved — the gateway picks the model, the provider never guesses mid-request. */
@@ -22,6 +25,8 @@ export interface ChatCompletionBodyInput {
22
25
  readonly stream: boolean;
23
26
  /** The endpoint, for `PROMPT_CACHE_RULES` — which host this is decides what a cache hint sends. */
24
27
  readonly baseUrl?: string | undefined;
28
+ /** The provider's own screened rows (`promptCache.rules`), read before the table. */
29
+ readonly cacheRows?: readonly ProviderCacheRow[];
25
30
  }
26
31
 
27
32
  /**
@@ -38,7 +43,7 @@ export function chatCompletionBody(input: ChatCompletionBodyInput): Record<strin
38
43
  const spec = modelSpec(model);
39
44
  assertMediaContent(request.messages, { provider, model });
40
45
  const hint = request.promptCache;
41
- const rule = promptCacheRule('openai', input.baseUrl, model);
46
+ const rule = promptCacheRule('openai', input.baseUrl, model, input.cacheRows);
42
47
  const marked = hint !== undefined && rule.marks === 'parts';
43
48
  const body: Record<string, unknown> = {
44
49
  model,
@@ -71,15 +76,17 @@ export function chatCompletionBody(input: ChatCompletionBodyInput): Record<strin
71
76
  return body;
72
77
  }
73
78
 
74
- /** The headers a cache hint adds for this endpoint and model: OpenRouter's stickiness key, or none. */
79
+ /**
80
+ * The headers a cache hint adds under this request's resolved rule: OpenRouter's stickiness key,
81
+ * or none.
82
+ */
75
83
  export function sessionHeaders(
76
84
  request: GenerateRequest,
77
- model: ModelId,
78
- baseUrl: string | undefined,
85
+ rule: CacheRule,
79
86
  ): Readonly<Record<string, string>> {
80
87
  const session = request.promptCache?.session;
81
88
  if (session === undefined) return {};
82
- if (promptCacheRule('openai', baseUrl, model).session !== 'x-session-id') return {};
89
+ if (rule.session !== 'x-session-id') return {};
83
90
  return { 'x-session-id': sessionKey(session) };
84
91
  }
85
92