@ultimat3/ai 30.9.0 → 31.1.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.
@@ -3,7 +3,7 @@
3
3
  // core `breaker()` per provider: a provider down for an hour cost `attempts` calls plus backoff on
4
4
  // EVERY call; behind its breaker it costs `attempts × failures` per cooldown, then none.
5
5
 
6
- import type { Breaker, BreakerTicket, Random } from '@ultimat3/core';
6
+ import type { Breaker, BreakerTicket, Random, RetryPolicy } from '@ultimat3/core';
7
7
  import {
8
8
  backoffDelay,
9
9
  breaker,
@@ -12,10 +12,10 @@ import {
12
12
  renderThrowable,
13
13
  settleWith,
14
14
  stringField,
15
+ systemClock,
15
16
  tryUseContext,
16
17
  } from '@ultimat3/core';
17
18
  import { AiProviderUnavailableError } from './errors';
18
- import type { RetryPolicy } from './gateway';
19
19
  import type { ModelId } from './models';
20
20
  import type { GenerateResult, Provider } from './provider';
21
21
 
@@ -33,17 +33,18 @@ export const DEFAULT_GATEWAY_BREAKER = Object.freeze({ failures: 5, cooldownMs:
33
33
  const PROVIDER_UNAVAILABLE = 'X_AI_PROVIDER_UNAVAILABLE';
34
34
 
35
35
  /**
36
- * Full jitter: a uniform pick from [0, exponential], capped BEFORE the roll — core's
37
- * `backoffDelay` with the gateway's own field names mapped onto it, and nothing else.
38
- * `gateway-backoff.test.ts` pins the refusal of a non-finite bound and the clamped negative base.
36
+ * The wait before `attempt + 1`: core's `backoffDelay` over core's `RetryPolicy`, every curve field
37
+ * the policy's own (`DEFAULT_GATEWAY_RETRY` is full jitter). `gateway-backoff.test.ts` pins the
38
+ * refusal of a non-finite bound and the clamped negative base.
39
39
  */
40
40
  export function backoffMs(policy: RetryPolicy, attempt: number, random?: Random): number {
41
41
  return backoffDelay({
42
42
  attempt,
43
- base: policy.baseDelayMs,
44
- max: policy.maxDelayMs,
45
- curve: 'exponential',
46
- jitter: 'full',
43
+ base: policy.base,
44
+ max: policy.max,
45
+ factor: policy.factor,
46
+ curve: policy.curve,
47
+ jitter: policy.jitter,
47
48
  random,
48
49
  });
49
50
  }
@@ -122,6 +123,12 @@ export interface AttemptInput {
122
123
  export async function attemptProviders(input: AttemptInput): Promise<GenerateResult> {
123
124
  const { model, retry } = input;
124
125
  const failures: string[] = [];
126
+ // Read once, and only compared when a budget is set: core's `retryLoop` rule — a wait that would
127
+ // end past `timeBudgetMs` is never started, across every provider of this call.
128
+ const startedAt = systemClock.monotonic();
129
+ const overruns = (delayMs: number): boolean =>
130
+ retry.timeBudgetMs !== undefined &&
131
+ systemClock.monotonic() - startedAt + delayMs > retry.timeBudgetMs;
125
132
  if (input.candidates.length === 0) {
126
133
  throw new AiProviderUnavailableError({
127
134
  model,
@@ -164,7 +171,12 @@ export async function attemptProviders(input: AttemptInput): Promise<GenerateRes
164
171
  exhausted(gate, ticket);
165
172
  break;
166
173
  }
167
- await input.sleep(backoffMs(retry, attempt, input.random));
174
+ const delayMs = backoffMs(retry, attempt, input.random);
175
+ if (overruns(delayMs)) {
176
+ exhausted(gate, ticket);
177
+ throw new AiProviderUnavailableError({ model, attempts: failures });
178
+ }
179
+ await input.sleep(delayMs);
168
180
  }
169
181
  }
170
182
  }
package/src/gateway.ts CHANGED
@@ -4,8 +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 { Breaker, Random } from '@ultimat3/core';
8
- import { fingerprint, finiteCount, logger, renderThrowable } from '@ultimat3/core';
7
+ import type { Breaker, Random, RetryPolicy } from '@ultimat3/core';
8
+ import { fingerprint, finiteCount, finiteOption, logger, renderThrowable } from '@ultimat3/core';
9
9
  import type { Money } from '@ultimat3/money';
10
10
  import { billedOf } from './billed';
11
11
  import type { BudgetKeys, BudgetLimits, BudgetReservation, BudgetStore } from './budget';
@@ -39,24 +39,15 @@ export interface GatewayCache {
39
39
  }
40
40
 
41
41
  /**
42
- * A gateway's retry budget. NOT core's `RetryPolicy`, and deliberately still its own declaration:
43
- * these three field names are what an app writes in `providerGateway({ retry })`, so renaming them
44
- * onto `base`/`max`/`jitter` would break every caller for no behaviour. What WAS a duplicate is the
45
- * arithmetic, and that is gone — `backoffMs` is core's `backoffDelay` with this shape mapped onto
46
- * it, so there is one curve in the framework and one place a jitter bug can live.
42
+ * The gateway's retry when `providerGateway({ retry })` names none. Core's `RetryPolicy`, the one
43
+ * retry shape in the framework since 31.0.0: full jitter, because N workers backing off a rate limit
44
+ * in step reproduce it.
47
45
  */
48
- export interface RetryPolicy {
49
- /** Total attempts including the first. */
50
- readonly attempts: number;
51
- /** First backoff in ms; doubled per attempt with full jitter. */
52
- readonly baseDelayMs: number;
53
- readonly maxDelayMs: number;
54
- }
55
-
56
46
  export const DEFAULT_GATEWAY_RETRY: RetryPolicy = {
57
47
  attempts: 3,
58
- baseDelayMs: 500,
59
- maxDelayMs: 8_000,
48
+ base: 500,
49
+ max: 8_000,
50
+ jitter: 'full',
60
51
  };
61
52
 
62
53
  export type { GatewayBreakerOption } from './gateway-providers';
@@ -68,6 +59,10 @@ export interface ProviderGatewayInput {
68
59
  readonly budget?: BudgetLimits;
69
60
  readonly budgetStore?: BudgetStore;
70
61
  readonly cache?: GatewayCache;
62
+ /**
63
+ * Core's `RetryPolicy`, read per provider: `attempts` each, `base`/`max`/`curve`/`factor`/`jitter`
64
+ * for the wait, and `timeBudgetMs` across the whole call, every provider included.
65
+ */
71
66
  readonly retry?: RetryPolicy;
72
67
  readonly defaultModel?: ModelId;
73
68
  /** Overridable so a test can run backoff without waiting. */
@@ -88,7 +83,7 @@ export interface ProviderGatewayInput {
88
83
  }
89
84
 
90
85
  export interface Gateway {
91
- /** The wire formats of the providers serving `model` — what `llm.sampling.ignored` is read from. */
86
+ /** The wire formats of the providers serving `model` — what `ultimate.llm.sampling.ignored` is read from. */
92
87
  samplingWires?(model: ModelId): readonly SamplingWire[];
93
88
  /**
94
89
  * Whether a provider serving `model` would send a hinted request with a cache mark — so its
@@ -142,10 +137,14 @@ class GatewayImpl implements Gateway {
142
137
  // `X_AI_PROVIDER_UNAVAILABLE` with an EMPTY attempt list — measured, "no provider could serve
143
138
  // model <id> ()" for a provider that was never asked. A floor of 1 because the field
144
139
  // is documented as total attempts INCLUDING the first, so zero of them is not a policy.
145
- // `baseDelayMs` and `maxDelayMs` are deliberately not screened here: `backoffDelay` refuses a
146
- // non-finite one already, and a NEGATIVE base is clamped to a zero wait on purpose
147
- // (`gateway-backoff.test.ts` pins it), which a count check here would start refusing.
140
+ // `base` and `max` are deliberately not screened here: `backoffDelay` refuses a non-finite one
141
+ // already, and a NEGATIVE base is clamped to a zero wait on purpose (`gateway-backoff.test.ts`
142
+ // pins it), which a count check here would start refusing. The budget IS screened: a NaN one
143
+ // compares false against every elapsed time, so it would never stop anything.
148
144
  finiteCount('gateway', 'retry.attempts', this.retry.attempts, 1);
145
+ if (this.retry.timeBudgetMs !== undefined) {
146
+ finiteOption('gateway', 'retry.timeBudgetMs', this.retry.timeBudgetMs);
147
+ }
149
148
  // Screened here, under the config's own key, rather than by the ledger `callLedger` builds:
150
149
  // that refused on the first CALL and named `the AI budget`, not the `gateway` to edit.
151
150
  if (config.budget !== undefined) assertFiniteLimits(config.budget, 'gateway', 'budget.');
package/src/index.ts CHANGED
@@ -46,7 +46,7 @@ export {
46
46
  LlmTruncatedError,
47
47
  } from './answer-errors';
48
48
  export type { Billed } from './billed';
49
- export { BILLED_META, billedOf } from './billed';
49
+ export { billedOf } from './billed';
50
50
  export type {
51
51
  BudgetKeys,
52
52
  BudgetLedger,
@@ -65,21 +65,6 @@ export {
65
65
  memoryBudgetStore,
66
66
  withBudget,
67
67
  } from './budget';
68
- export type {
69
- AiDocumentBlock,
70
- AiDocumentSource,
71
- AiImageBlock,
72
- AiImageSource,
73
- AiMediaBlock,
74
- ImageMediaType,
75
- } from './content-blocks';
76
- export {
77
- IMAGE_MEDIA_TYPES,
78
- IMAGE_TOKEN_ESTIMATE,
79
- isMediaBlock,
80
- MAX_DOCUMENT_BASE64_CHARS,
81
- MAX_IMAGE_BASE64_CHARS,
82
- } from './content-blocks';
83
68
  export type { ContentRefusal } from './content-errors';
84
69
  export { AiContentUnsupportedError } from './content-errors';
85
70
  export { AiContextOverflowError } from './context-overflow';
@@ -101,12 +86,6 @@ export {
101
86
  AgentMaxTurnsError,
102
87
  AgentToolUnexposedError,
103
88
  AiBudgetExceededError,
104
- AiGatewayMissingError,
105
- AiKeyMissingError,
106
- AiModelUnknownError,
107
- AiPromptRenderError,
108
- AiPromptSecretError,
109
- AiPromptVersionError,
110
89
  AiProviderUnavailableError,
111
90
  AiRequestInvalidError,
112
91
  AiTransportError,
@@ -119,17 +98,8 @@ export {
119
98
  } from './errors';
120
99
  export { AI_ERROR_RETRY } from './errors-retry';
121
100
  export type { EvalBaseline, Regression } from './eval-baseline';
101
+ export { baselinePath, RECORD_ENV, readBaseline, recordingBaselines } from './eval-baseline';
122
102
  export {
123
- baselinePath,
124
- describeRegression,
125
- RECORD_ENV,
126
- readBaseline,
127
- recordingBaselines,
128
- regressionsAgainst,
129
- writeBaseline,
130
- } from './eval-baseline';
131
- export {
132
- EvalBaselineInvalidError,
133
103
  EvalBaselineMissingError,
134
104
  EvalMissingError,
135
105
  EvalRecordingError,
@@ -148,24 +118,16 @@ export type {
148
118
  EvaluatedAgent,
149
119
  PromptEvalInput,
150
120
  } from './evals';
151
- export {
152
- baselineFrom,
153
- defineEval,
154
- describeEvals,
155
- promptsWithoutEvals,
156
- resetEvals,
157
- } from './evals';
121
+ export { defineEval, describeEvals, promptsWithoutEvals, resetEvals } from './evals';
158
122
  export type { AiFetch } from './fetch-seam';
159
123
  export type {
160
124
  Gateway,
161
125
  GatewayBreakerOption,
162
126
  GatewayCache,
163
127
  ProviderGatewayInput,
164
- RetryPolicy,
165
128
  } from './gateway';
166
129
  export {
167
130
  backoffMs,
168
- DEFAULT_GATEWAY_BREAKER,
169
131
  DEFAULT_GATEWAY_RETRY,
170
132
  isRetryable,
171
133
  promptCacheKey,
@@ -173,7 +135,6 @@ export {
173
135
  } from './gateway';
174
136
  export type { HiveDef, HiveSplitArgs } from './hive';
175
137
  export { hive } from './hive';
176
- export { HiveEmptyError } from './hive-errors';
177
138
  export type { HiveMember, HiveMemberError, HiveOutput, HiveResult } from './hive-result';
178
139
  export type { InProcessToolDef } from './in-process-tool';
179
140
  export { inProcessTool } from './in-process-tool';
@@ -182,7 +143,6 @@ export { LlmTextChunk, llm } from './llm';
182
143
  export type { LlmCache, LlmScopeArgs, LlmSemanticCache } from './llm-cache';
183
144
  export type { LlmStreamChunk } from './llm-stream';
184
145
  export type { ModelSource } from './model-resolve';
185
- export { AiModelUnresolvedError } from './model-resolve';
186
146
  export type {
187
147
  ContentKind,
188
148
  DeclaredReasoning,
@@ -193,10 +153,7 @@ export type {
193
153
  ThinkingMode,
194
154
  } from './models';
195
155
  export {
196
- assertModel,
197
- EFFORTS,
198
156
  isModelRegistered,
199
- modelIds,
200
157
  modelSpec,
201
158
  moreCapableThan,
202
159
  registeredModels,
@@ -207,12 +164,7 @@ export type { OpenAiProviderInput } from './openai-provider';
207
164
  export { openAiProvider } from './openai-provider';
208
165
  export type { PostgresVectorStore, PostgresVectorStoreInput } from './pg-vector';
209
166
  export { postgresVectorStore } from './pg-vector';
210
- export type {
211
- PgHybridArgs,
212
- PgSearchArgs,
213
- PgVectorRowInput,
214
- PgVectorTable,
215
- } from './pg-vector-sql';
167
+ export type { PgSearchArgs, PgVectorTable } from './pg-vector-sql';
216
168
  export {
217
169
  conditionsSql,
218
170
  ddlSql,
@@ -220,14 +172,7 @@ export {
220
172
  searchSql,
221
173
  } from './pg-vector-sql';
222
174
  export type { DefinePromptInput, Prompt, PromptVars } from './prompt';
223
- export {
224
- definePrompt,
225
- describePrompts,
226
- getPrompt,
227
- promptHash,
228
- promptVersions,
229
- resetPrompts,
230
- } from './prompt';
175
+ export { definePrompt, describePrompts, promptHash, resetPrompts } from './prompt';
231
176
  export type {
232
177
  CacheWire,
233
178
  PromptCacheHint,
@@ -264,14 +209,11 @@ export {
264
209
  costOf,
265
210
  estimateCost,
266
211
  estimateInputTokens,
267
- estimateTextTokens,
268
212
  estimateTokens,
269
213
  isTruncated,
270
214
  messageText,
271
215
  parseMessage,
272
- requiresStreaming,
273
216
  STREAM_ONLY_MAX_TOKENS,
274
- totalTokens,
275
217
  } from './provider';
276
218
  export type {
277
219
  AssembledContext,
@@ -280,13 +222,7 @@ export type {
280
222
  Reranker,
281
223
  RetrieveInput,
282
224
  } from './rag';
283
- export {
284
- assembleContext,
285
- chunkDocument,
286
- indexDocument,
287
- passthroughReranker,
288
- retrieve,
289
- } from './rag';
225
+ export { assembleContext, chunkDocument, indexDocument, retrieve } from './rag';
290
226
  export { assertNoSecrets } from './redaction';
291
227
  export type { RemoteEmbedder, RemoteEmbedderInput } from './remote-embedder';
292
228
  export { remoteEmbedder } from './remote-embedder';
@@ -339,4 +275,3 @@ export type {
339
275
  export { fuse, memoryVectorStore } from './vector';
340
276
  export type { VectorScope } from './vector-scope';
341
277
  export { NO_VECTOR_TENANT, tenantOf, UNSCOPED, VectorUnscopedError } from './vector-scope';
342
- export type { StreamState } from './wire';
@@ -0,0 +1,125 @@
1
+ // Single responsibility: what one answered `llm()` turn amounts to — its span attributes, its
2
+ // `onCall` report, whether a value fits the declared `output`, and the one streamed turn's answer
3
+ // under the same rules. Split from ./llm at its line ceiling.
4
+
5
+ import type { Span, SpanAttributes } from '@ultimat3/core';
6
+ import { SEMCONV } from '@ultimat3/core';
7
+ import type { InferOutput, StandardSchemaV1 } from '@ultimat3/schema';
8
+ import { formatIssues, validateAsync } from '@ultimat3/schema';
9
+ import { runTotal } from './agent-usage';
10
+ import { LlmTruncatedError } from './answer-errors';
11
+ import { LlmRefusedError, LlmStreamInvalidError } from './errors';
12
+ import type { Gateway } from './gateway';
13
+ import type { LlmDef } from './llm-def';
14
+ import type { LlmSink } from './llm-stream';
15
+ import { streamOneTurn } from './llm-stream';
16
+ import { moreCapableThan } from './models';
17
+ import type { PromptVars } from './prompt';
18
+ import type { GenerateRequest, GenerateResult } from './provider';
19
+ import { isTruncated } from './provider';
20
+ import { parseJsonish } from './respond';
21
+ import type { RunMeter } from './run-meter';
22
+
23
+ /**
24
+ * What one answered turn puts on the span. The provider that answered is the half the LLM-gateway
25
+ * table called "a fallback is recorded in the span, never silent": fallback in this framework is
26
+ * across PROVIDERS serving one model, never across models, and until the gateway stamped the
27
+ * provider that answered, a fallback was exactly as silent as no fallback at all. It lands as
28
+ * semconv's `gen_ai.provider.name`, with the token usage, when the call settles (`llm-instrument.ts`).
29
+ */
30
+ export function answerAttributes(result: GenerateResult): SpanAttributes {
31
+ return {
32
+ 'ultimate.llm.stop': result.stopReason,
33
+ 'ultimate.llm.cost.minor': result.cost.minor,
34
+ [SEMCONV.genAiProviderName]: result.provider ?? 'unknown',
35
+ [SEMCONV.genAiResponseModel]: result.model,
36
+ };
37
+ }
38
+
39
+ /**
40
+ * One streamed turn, from the same declaration and under the same rules — with one difference
41
+ * that is forced rather than chosen: no repair turn. The tokens are already on the consumer's
42
+ * screen, so a second answer would be two answers to one question; a stream gets one attempt and
43
+ * `X_LLM_STREAM_INVALID` names the non-streaming call as the fix.
44
+ *
45
+ * The stop reason is still read BEFORE the answer, for the reason it always was: a refusal
46
+ * carries empty or partial content and parsing it first reports a schema disagreement that is not
47
+ * one. Truncation is the same call it is on the non-streaming path.
48
+ */
49
+ export async function streamedAnswer<
50
+ TInput extends StandardSchemaV1,
51
+ TOutput extends StandardSchemaV1,
52
+ V extends PromptVars,
53
+ >(
54
+ def: LlmDef<TInput, TOutput, V>,
55
+ name: string,
56
+ gateway: Gateway,
57
+ request: GenerateRequest,
58
+ sink: LlmSink,
59
+ span: Span,
60
+ meter: RunMeter,
61
+ ): Promise<InferOutput<TOutput>> {
62
+ const { output } = def;
63
+ const result = await meter.call(streamOneTurn(gateway, request, sink));
64
+ span.setAttributes({ 'ultimate.llm.attempts': 1, ...answerAttributes(result) });
65
+ await observeCall(def, meter, result, 1, undefined);
66
+ if (result.stopReason === 'refusal') {
67
+ throw new LlmRefusedError({
68
+ prompt: name,
69
+ model: result.model,
70
+ alternative: moreCapableThan(result.model, request),
71
+ category: result.stopDetails?.category,
72
+ explanation: result.stopDetails?.explanation,
73
+ });
74
+ }
75
+ if (isTruncated(result.stopReason)) {
76
+ throw new LlmTruncatedError({ prompt: name, maxTokens: request.maxTokens });
77
+ }
78
+ // Prose, and its JSON parse when it has one. Both, because a stream carries no `respond` tool
79
+ // to tell them apart: `output: t.string` is satisfied by the text itself, and an object schema
80
+ // by what the text parses to — trying only one of the two makes a legal declaration unusable.
81
+ const parsed = await accept(output, parseJsonish(result.text));
82
+ if (parsed !== undefined) return parsed.value;
83
+ const fallback = await validateAsync(output, result.text);
84
+ if (fallback.issues === undefined) return fallback.value;
85
+ throw new LlmStreamInvalidError({
86
+ prompt: name,
87
+ issues: formatIssues(fallback.issues).join('; '),
88
+ });
89
+ }
90
+
91
+ /** One provider call, reported to the declaration's `onCall`. Awaited; a throw fails the call. */
92
+ export async function observeCall<
93
+ TInput extends StandardSchemaV1,
94
+ TOutput extends StandardSchemaV1,
95
+ V extends PromptVars,
96
+ >(
97
+ def: LlmDef<TInput, TOutput, V>,
98
+ meter: RunMeter,
99
+ result: GenerateResult,
100
+ attempt: number,
101
+ repairOf: readonly string[] | undefined,
102
+ ): Promise<void> {
103
+ if (def.onCall === undefined) return;
104
+ await def.onCall({
105
+ attempt,
106
+ model: result.model,
107
+ provider: result.provider,
108
+ stopReason: result.stopReason,
109
+ usage: result.usage,
110
+ cost: result.cost,
111
+ ...(result.costExact === undefined ? {} : { costExact: result.costExact }),
112
+ ...(repairOf === undefined ? {} : { repairOf }),
113
+ run: runTotal(result.model, undefined, meter.total()),
114
+ });
115
+ }
116
+
117
+ /** `undefined` for "does not fit". Wrapped so a legitimately falsy value is still a hit. */
118
+ export async function accept<TOutput extends StandardSchemaV1>(
119
+ schema: TOutput,
120
+ value: unknown,
121
+ ): Promise<{ readonly value: InferOutput<TOutput> } | undefined> {
122
+ if (value === undefined) return undefined;
123
+ const parsed = await validateAsync(schema, value);
124
+ return parsed.issues === undefined ? { value: parsed.value } : undefined;
125
+ }
package/src/llm-cache.ts CHANGED
@@ -66,7 +66,9 @@ export interface PromptCache {
66
66
 
67
67
  /**
68
68
  * The partition a declaration that named none gets: the narrowest key this call can prove, which
69
- * is the actor itself. Verbatim the shape `@ultimat3/query`'s `actorAuthority` builds, for the
69
+ * is the actor itself AND its grant set — one id is not one grant set (a role revoked inside the
70
+ * `ttl`, one identity holding two role sets), and `vars()` may read `ctx.actor.roles`, so an
71
+ * id-only key handed a member an admin's answer. Verbatim the shape `@ultimat3/query`'s `actorAuthority` builds, for the
70
72
  * same reason it is JSON rather than a joined string — an actor id is app data and may carry any
71
73
  * separator, and a value that can spell a boundary can spell somebody else's.
72
74
  *
@@ -79,9 +81,21 @@ export interface PromptCache {
79
81
  * everywhere else in the framework.
80
82
  */
81
83
  function actorScope(ctx: Ctx): string {
82
- return JSON.stringify([ctx.actor.kind, ctx.actor.id, ctx.actor.orgId ?? null]);
84
+ const { actor } = ctx;
85
+ return JSON.stringify([
86
+ actor.kind,
87
+ actor.id,
88
+ actor.orgId ?? null,
89
+ grantSet(actor.roles),
90
+ grantSet(actor.scopes),
91
+ grantSet(actor.permissions),
92
+ actor.onBehalfOf === undefined ? null : [actor.onBehalfOf.actorKind, actor.onBehalfOf.actorId],
93
+ ]);
83
94
  }
84
95
 
96
+ /** Sorted, de-duplicated: a set, so two orderings of one grant are one partition. */
97
+ const grantSet = (values: readonly string[]): readonly string[] => [...new Set(values)].sort();
98
+
85
99
  /**
86
100
  * The semantic cache for one declaration, or `undefined` when none was declared. The instance
87
101
  * is partitioned by prompt VERSION as well as scope, which is what makes "editing a prompt
package/src/llm-def.ts CHANGED
@@ -61,7 +61,7 @@ export interface LlmDef<
61
61
  * `temperature` (0 to 2) and `seed` (a whole number), for a call that should answer the same way
62
62
  * twice — a verifier, a classifier. Sent only to a model whose row takes them
63
63
  * (`registerModel({ sampling })`); to any other they are NOT sent, because a reasoning model
64
- * answers one with a 400 — the `llm.sampling.ignored` span attribute names what was left out.
64
+ * answers one with a 400 — the `ultimate.llm.sampling.ignored` span attribute names what was left out.
65
65
  * `seed` exists on the chat-completions wire only, and is best effort where it exists.
66
66
  */
67
67
  readonly temperature?: number;
@@ -0,0 +1,100 @@
1
+ // Single responsibility: what one `llm()` call reports when it settles — the `ai.llm` event on
2
+ // core's instrumentation bus and the OpenTelemetry gen_ai usage on its span — on BOTH paths, a
3
+ // cache hit included. Split from ./llm at its line ceiling; the meter is the one place every
4
+ // provider call passes, so it is where the answering provider and model are read.
5
+
6
+ import type { Span } from '@ultimat3/core';
7
+ import { instrument, SEMCONV, subscribed } from '@ultimat3/core';
8
+ import type { GenerateResult } from './provider';
9
+ import type { RunMeter } from './run-meter';
10
+
11
+ /** What a cache hit, or a call no provider ever answered, reports as its provider. */
12
+ export const NO_PROVIDER = 'unknown';
13
+
14
+ export interface LlmReport {
15
+ /** The run's meter, watched: hand THIS to every provider call so the answerer is seen. */
16
+ readonly meter: RunMeter;
17
+ /** Settle the call: usage onto the span, `ai.llm` onto the bus. Never throws of its own. */
18
+ settle(outcome: { readonly cacheHit: boolean; readonly error?: unknown }): void;
19
+ }
20
+
21
+ /** Start reporting one `llm()` call, requested of `model`, under `span`. */
22
+ export function llmReport(span: Span, model: string, meter: RunMeter): LlmReport {
23
+ const startedAt = performance.now();
24
+ let provider = NO_PROVIDER;
25
+ let answeredBy = model;
26
+ const watched: RunMeter = {
27
+ ...meter,
28
+ call: async (work: Promise<GenerateResult>): Promise<GenerateResult> => {
29
+ const result = await meter.call(work);
30
+ provider = result.provider ?? NO_PROVIDER;
31
+ answeredBy = result.model;
32
+ return result;
33
+ },
34
+ };
35
+ return {
36
+ meter: watched,
37
+ settle({ cacheHit, error }) {
38
+ // The RUN's usage — every attempt, repair turns included — not the last answer's.
39
+ const usage = meter.total()?.usage;
40
+ const inputTokens = usage?.inputTokens ?? 0;
41
+ const outputTokens = usage?.outputTokens ?? 0;
42
+ span.setAttributes({
43
+ [SEMCONV.genAiProviderName]: provider,
44
+ [SEMCONV.genAiResponseModel]: answeredBy,
45
+ [SEMCONV.genAiUsageInputTokens]: inputTokens,
46
+ [SEMCONV.genAiUsageOutputTokens]: outputTokens,
47
+ });
48
+ if (!subscribed('ai.llm')) return;
49
+ instrument('ai.llm', {
50
+ provider,
51
+ model: answeredBy,
52
+ inputTokens,
53
+ outputTokens,
54
+ cacheHit,
55
+ durationMs: performance.now() - startedAt,
56
+ ...(error === undefined ? {} : { error }),
57
+ });
58
+ },
59
+ };
60
+ }
61
+
62
+ /**
63
+ * ONE model call an `agent()` makes — a turn, or a compaction's summary — as `ai.llm`: the token
64
+ * metric is per call, and an agent's spend would otherwise reach no subscriber. Emitted on both
65
+ * paths; a failed call reports the model it ASKED for, no provider, and no tokens.
66
+ */
67
+ export async function reportedCall(
68
+ model: string,
69
+ call: Promise<GenerateResult>,
70
+ ): Promise<GenerateResult> {
71
+ const startedAt = performance.now();
72
+ let result: GenerateResult;
73
+ try {
74
+ result = await call;
75
+ } catch (error) {
76
+ if (subscribed('ai.llm')) {
77
+ instrument('ai.llm', {
78
+ provider: NO_PROVIDER,
79
+ model,
80
+ inputTokens: 0,
81
+ outputTokens: 0,
82
+ cacheHit: false,
83
+ durationMs: performance.now() - startedAt,
84
+ error,
85
+ });
86
+ }
87
+ throw error;
88
+ }
89
+ if (subscribed('ai.llm')) {
90
+ instrument('ai.llm', {
91
+ provider: result.provider ?? NO_PROVIDER,
92
+ model: result.model,
93
+ inputTokens: result.usage.inputTokens,
94
+ outputTokens: result.usage.outputTokens,
95
+ cacheHit: false,
96
+ durationMs: performance.now() - startedAt,
97
+ });
98
+ }
99
+ return result;
100
+ }