@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.
package/src/llm.ts CHANGED
@@ -23,25 +23,20 @@
23
23
 
24
24
  import type { InvokeOptions, StreamAction } from '@ultimat3/action';
25
25
  import { action, chunksRead } from '@ultimat3/action';
26
- import type { Ctx, Span, SpanAttributes } from '@ultimat3/core';
27
- import { finiteCount, withSpan } from '@ultimat3/core';
26
+ import type { Ctx } from '@ultimat3/core';
27
+ import { finiteCount, SEMCONV, withSpan } from '@ultimat3/core';
28
28
  import type { InferInput, InferOutput, StandardSchemaV1 } from '@ultimat3/schema';
29
29
  import { formatIssues, t, validateAsync } from '@ultimat3/schema';
30
- import { runTotal } from './agent-usage';
31
30
  import { LlmTruncatedError } from './answer-errors';
32
31
  import type { BudgetLimits } from './budget';
33
32
  import { budgetKeysFor, currentBudget, screenedMoney, withBudget } from './budget';
34
- import {
35
- AiTransportError,
36
- LlmOutputInvalidError,
37
- LlmRefusedError,
38
- LlmStreamInvalidError,
39
- } from './errors';
40
- import type { Gateway } from './gateway';
33
+ import { AiTransportError, LlmOutputInvalidError, LlmRefusedError } from './errors';
34
+ import { accept, answerAttributes, observeCall, streamedAnswer } from './llm-answer';
41
35
  import { openCache } from './llm-cache';
42
36
  import type { LlmBudget, LlmDef } from './llm-def';
43
- import type { LlmSink, LlmStreamChunk } from './llm-stream';
44
- import { currentLlmSink, llmStream, streamOneTurn, withLlmSink } from './llm-stream';
37
+ import { llmReport } from './llm-instrument';
38
+ import type { LlmStreamChunk } from './llm-stream';
39
+ import { currentLlmSink, llmStream, withLlmSink } from './llm-stream';
45
40
  import { modelForCall } from './model-resolve';
46
41
  import { moreCapableThan } from './models';
47
42
  import type { PromptVars } from './prompt';
@@ -50,8 +45,7 @@ import type { AiMessage, GenerateRequest, GenerateResult } from './provider';
50
45
  import { isTruncated } from './provider';
51
46
  import { assertNoSecrets } from './redaction';
52
47
  import type { Respond } from './respond';
53
- import { parseJsonish, RESPOND, respondFor } from './respond';
54
- import type { RunMeter } from './run-meter';
48
+ import { RESPOND, respondFor } from './respond';
55
49
  import { runMeter } from './run-meter';
56
50
  import { aiGateway, aiRedactor, installedGateway } from './runtime';
57
51
  import type { Sampling } from './sampling';
@@ -204,239 +198,156 @@ async function generate<
204
198
  const redacted = rendered !== rawPrompt || system !== prompt.system;
205
199
  const promptCache = promptCacheHint(def.promptCache, 'off', args);
206
200
 
207
- return withSpan('ai.llm', async (span) => {
208
- span.setAttributes({
209
- 'llm.model': model,
210
- 'llm.prompt': prompt.ref,
211
- 'llm.prompt.hash': prompt.hash,
212
- // Whether the installed redactor changed anything. Recorded because "we redact" is a claim
213
- // an audit asks evidence for, and a redactor that silently stopped matching looks identical
214
- // to one that had nothing to remove until this attribute separates them.
215
- 'llm.redacted': redacted,
216
- });
217
- // Read when the call ends, not here: the gateway may be what registers the model's row.
218
- const reportSampling = (): void => {
219
- span.setAttributes(
220
- ignoredSamplingAttribute(model, sampling, () => installedGateway()?.samplingWires?.(model)),
221
- );
222
- };
201
+ return withSpan(
202
+ 'ai.llm',
203
+ async (span) => {
204
+ // OpenTelemetry's gen_ai names for what semconv covers (v31); the framework's own facts are
205
+ // `ultimate.llm.*`. The provider, response model and token usage land when the call settles.
206
+ span.setAttributes({
207
+ [SEMCONV.genAiOperationName]: 'chat',
208
+ [SEMCONV.genAiRequestModel]: model,
209
+ 'ultimate.llm.prompt': prompt.ref,
210
+ 'ultimate.llm.prompt.hash': prompt.hash,
211
+ // Whether the installed redactor changed anything. Recorded because "we redact" is a claim
212
+ // an audit asks evidence for, and a redactor that silently stopped matching looks identical
213
+ // to one that had nothing to remove until this attribute separates them.
214
+ 'ultimate.llm.redacted': redacted,
215
+ });
216
+ // Read when the call ends, not here: the gateway may be what registers the model's row.
217
+ const reportSampling = (): void => {
218
+ span.setAttributes(
219
+ ignoredSamplingAttribute(model, sampling, () =>
220
+ installedGateway()?.samplingWires?.(model),
221
+ ),
222
+ );
223
+ };
223
224
 
224
- // A cached answer is still data of unknown provenance, so it goes through the schema like
225
- // any other. One that no longer fits — the schema moved under it — is a miss, not a
226
- // failure: the model can produce a fresh answer, and refusing would be worse than paying.
227
- const cache = await openCache({
228
- cache: def.cache,
229
- prompt: { ref: prompt.ref, hash: prompt.hash },
230
- input: args.input,
231
- ctx: args.ctx,
232
- rendered,
233
- });
234
- const hit = await accept(def.output, await cache?.lookup());
235
- span.setAttribute('llm.cache.hit', hit !== undefined);
236
- if (hit !== undefined) {
237
- reportSampling();
238
- return hit.value;
239
- }
225
+ // A cached answer is still data of unknown provenance, so it goes through the schema like
226
+ // any other. One that no longer fits — the schema moved under it — is a miss, not a
227
+ // failure: the model can produce a fresh answer, and refusing would be worse than paying.
228
+ const cache = await openCache({
229
+ cache: def.cache,
230
+ prompt: { ref: prompt.ref, hash: prompt.hash },
231
+ input: args.input,
232
+ ctx: args.ctx,
233
+ rendered,
234
+ });
235
+ const hit = await accept(def.output, await cache?.lookup());
236
+ span.setAttribute('ultimate.llm.cache.hit', hit !== undefined);
237
+ // Every provider call below goes through this meter, so the report sees who answered.
238
+ const report = llmReport(span, model, runMeter());
239
+ const meter = report.meter;
240
+ if (hit !== undefined) {
241
+ reportSampling();
242
+ report.settle({ cacheHit: true });
243
+ return hit.value;
244
+ }
240
245
 
241
- // No `tools` yet: the `respond` projection belongs to the non-streaming path alone. A tool
242
- // call is emitted whole, so forcing the answer through one leaves a stream with nothing to
243
- // deliver until it is already over.
244
- const base: GenerateRequest = {
245
- model,
246
- ...(system === undefined ? {} : { system }),
247
- ...(promptCache === undefined ? {} : { promptCache }),
248
- messages: [{ role: 'user', content: rendered }],
249
- maxTokens: def.maxTokens ?? DEFAULT_MAX_TOKENS,
250
- ...(prompt.effort === undefined ? {} : { effort: prompt.effort }),
251
- ...(prompt.thinking === undefined ? {} : { thinking: prompt.thinking }),
252
- ...sampling,
253
- // The caller's own signal, forwarded to the transport exactly as `agent()` does — and
254
- // inherited by `streamedAnswer` from this same `base`. Without it a disconnected caller left
255
- // the provider call in flight, billed and unread, and the repair turn bought a SECOND one.
256
- signal: args.ctx.signal,
257
- };
258
- const request: GenerateRequest = { ...base, tools: [respond.tool] };
246
+ // No `tools` yet: the `respond` projection belongs to the non-streaming path alone. A tool
247
+ // call is emitted whole, so forcing the answer through one leaves a stream with nothing to
248
+ // deliver until it is already over.
249
+ const base: GenerateRequest = {
250
+ model,
251
+ ...(system === undefined ? {} : { system }),
252
+ ...(promptCache === undefined ? {} : { promptCache }),
253
+ messages: [{ role: 'user', content: rendered }],
254
+ maxTokens: def.maxTokens ?? DEFAULT_MAX_TOKENS,
255
+ ...(prompt.effort === undefined ? {} : { effort: prompt.effort }),
256
+ ...(prompt.thinking === undefined ? {} : { thinking: prompt.thinking }),
257
+ ...sampling,
258
+ // The caller's own signal, forwarded to the transport exactly as `agent()` does — and
259
+ // inherited by `streamedAnswer` from this same `base`. Without it a disconnected caller left
260
+ // the provider call in flight, billed and unread, and the repair turn bought a SECOND one.
261
+ signal: args.ctx.signal,
262
+ };
263
+ const request: GenerateRequest = { ...base, tools: [respond.tool] };
259
264
 
260
- // A ledger derived from the ambient one, so a per-call budget can only TIGHTEN the actor
261
- // and org ceilings this call runs inside, never widen them. The gateway reserves against
262
- // it before the provider is touched — that is where `X_AI_BUDGET_EXCEEDED` comes from.
263
- // Rooted in the GATEWAY's own ceilings when no scope is open — an empty root ignored them.
264
- const gateway = aiGateway(name);
265
- // Keyed on the CALLER, so the gateway's `actor` / `org` ceilings count this call against them.
266
- const ledger = (currentBudget() ?? gateway.callLedger(budgetKeysFor(args.ctx.actor))).derive(
267
- limits,
268
- );
269
- const sink = currentLlmSink();
265
+ // A ledger derived from the ambient one, so a per-call budget can only TIGHTEN the actor
266
+ // and org ceilings this call runs inside, never widen them. The gateway reserves against
267
+ // it before the provider is touched — that is where `X_AI_BUDGET_EXCEEDED` comes from.
268
+ // Rooted in the GATEWAY's own ceilings when no scope is open — an empty root ignored them.
269
+ const gateway = aiGateway(name);
270
+ // Keyed on the CALLER, so the gateway's `actor` / `org` ceilings count this call against them.
271
+ const ledger = (currentBudget() ?? gateway.callLedger(budgetKeysFor(args.ctx.actor))).derive(
272
+ limits,
273
+ );
274
+ const sink = currentLlmSink();
270
275
 
271
- // Every provider call goes through the meter, so a failure AFTER one answered — a refusal, a
272
- // truncation, a schema no repair satisfied — is thrown carrying what it was billed.
273
- const meter = runMeter();
274
- const answer = async (): Promise<InferOutput<TOutput>> => {
275
- if (sink !== undefined) {
276
- const value = await streamedAnswer(def, name, gateway, base, sink, span, meter);
277
- await cache?.remember(value);
278
- return value;
279
- }
280
- let messages: readonly AiMessage[] = request.messages;
281
- let issues = 'no output';
282
- let rejected: readonly string[] | undefined;
283
- for (let attempt = 1; attempt <= ATTEMPTS; attempt += 1) {
284
- const result = await meter.call(gateway.generate({ ...request, messages }));
285
- span.setAttributes({ 'llm.attempts': attempt, ...answerAttributes(result) });
286
- await observeCall(def, meter, result, attempt, rejected);
287
- // Branch on the stop reason BEFORE reading the answer. A refusal carries empty or partial
288
- // content, so parsing it first reports a schema disagreement — a cause that is wrong, a
289
- // fix that does not apply, and a repair turn spent buying the same refusal again.
290
- if (result.stopReason === 'refusal') {
291
- throw new LlmRefusedError({
292
- prompt: name,
293
- model: result.model,
294
- // The fix names a model the caller can paste, and only ever a MORE capable one:
295
- // "the first id that differs" answered a refusal on the default model with the next
296
- // entry down the ladder, which is a retry that cannot succeed.
297
- alternative: moreCapableThan(result.model, request),
298
- category: result.stopDetails?.category,
299
- explanation: result.stopDetails?.explanation,
300
- });
301
- }
302
- // Cut off, so there is no answer to read — and it is read BEFORE the schema for the reason
303
- // the refusal is: a `respond` call stopped at the ceiling arrives with no arguments, both
304
- // wires read that as `{}`, and `{}` SATISFIES an `output` whose keys are optional. The
305
- // ceiling is the same on the next attempt, so the repair turn is a second truncation.
306
- if (isTruncated(result.stopReason)) {
307
- throw new LlmTruncatedError({ prompt: name, maxTokens: request.maxTokens });
276
+ // Every provider call goes through the meter, so a failure AFTER one answered — a refusal, a
277
+ // truncation, a schema no repair satisfied — is thrown carrying what it was billed.
278
+ const answer = async (): Promise<InferOutput<TOutput>> => {
279
+ if (sink !== undefined) {
280
+ const value = await streamedAnswer(def, name, gateway, base, sink, span, meter);
281
+ await cache?.remember(value);
282
+ return value;
308
283
  }
309
- const parsed = await validateAsync(def.output, respond.read(result));
310
- if (parsed.issues === undefined) {
311
- await cache?.remember(parsed.value);
312
- return parsed.value;
284
+ let messages: readonly AiMessage[] = request.messages;
285
+ let issues = 'no output';
286
+ let rejected: readonly string[] | undefined;
287
+ for (let attempt = 1; attempt <= ATTEMPTS; attempt += 1) {
288
+ const result = await meter.call(gateway.generate({ ...request, messages }));
289
+ span.setAttributes({ 'ultimate.llm.attempts': attempt, ...answerAttributes(result) });
290
+ await observeCall(def, meter, result, attempt, rejected);
291
+ // Branch on the stop reason BEFORE reading the answer. A refusal carries empty or partial
292
+ // content, so parsing it first reports a schema disagreement — a cause that is wrong, a
293
+ // fix that does not apply, and a repair turn spent buying the same refusal again.
294
+ if (result.stopReason === 'refusal') {
295
+ throw new LlmRefusedError({
296
+ prompt: name,
297
+ model: result.model,
298
+ // The fix names a model the caller can paste, and only ever a MORE capable one:
299
+ // "the first id that differs" answered a refusal on the default model with the next
300
+ // entry down the ladder, which is a retry that cannot succeed.
301
+ alternative: moreCapableThan(result.model, request),
302
+ category: result.stopDetails?.category,
303
+ explanation: result.stopDetails?.explanation,
304
+ });
305
+ }
306
+ // Cut off, so there is no answer to read — and it is read BEFORE the schema for the reason
307
+ // the refusal is: a `respond` call stopped at the ceiling arrives with no arguments, both
308
+ // wires read that as `{}`, and `{}` SATISFIES an `output` whose keys are optional. The
309
+ // ceiling is the same on the next attempt, so the repair turn is a second truncation.
310
+ if (isTruncated(result.stopReason)) {
311
+ throw new LlmTruncatedError({ prompt: name, maxTokens: request.maxTokens });
312
+ }
313
+ const parsed = await validateAsync(def.output, respond.read(result));
314
+ if (parsed.issues === undefined) {
315
+ await cache?.remember(parsed.value);
316
+ return parsed.value;
317
+ }
318
+ rejected = formatIssues(parsed.issues);
319
+ issues = rejected.join('; ');
320
+ const echo = assistantEcho(result);
321
+ messages =
322
+ echo === undefined
323
+ ? [...messages, repair(issues)]
324
+ : [...messages, { role: 'assistant', content: echo }, repair(issues)];
313
325
  }
314
- rejected = formatIssues(parsed.issues);
315
- issues = rejected.join('; ');
316
- const echo = assistantEcho(result);
317
- messages =
318
- echo === undefined
319
- ? [...messages, repair(issues)]
320
- : [...messages, { role: 'assistant', content: echo }, repair(issues)];
321
- }
322
- throw new LlmOutputInvalidError({
323
- prompt: name,
324
- attempts: ATTEMPTS,
325
- issues,
326
- ...(rejected === undefined ? {} : { list: rejected }),
327
- });
328
- };
329
- return withBudget(ledger, () =>
330
- answer()
331
- .catch((error: unknown) => {
332
- throw meter.fail(error);
333
- })
334
- .finally(reportSampling),
335
- );
336
- });
337
- }
338
-
339
- /**
340
- * What one answered turn puts on the span. `llm.provider` is the half the LLM-gateway table
341
- * called "a fallback is recorded in the span, never silent": fallback in this framework is across
342
- * PROVIDERS serving one model, never across models, and until the gateway stamped the provider
343
- * that answered, a fallback was exactly as silent as no fallback at all.
344
- */
345
- export function answerAttributes(result: GenerateResult): SpanAttributes {
346
- return {
347
- 'llm.stop': result.stopReason,
348
- 'llm.tokens': result.usage.inputTokens + result.usage.outputTokens,
349
- 'llm.cost.minor': result.cost.minor,
350
- 'llm.provider': result.provider ?? 'unknown',
351
- };
352
- }
353
-
354
- /**
355
- * One streamed turn, from the same declaration and under the same rules — with one difference
356
- * that is forced rather than chosen: no repair turn. The tokens are already on the consumer's
357
- * screen, so a second answer would be two answers to one question; a stream gets one attempt and
358
- * `X_LLM_STREAM_INVALID` names the non-streaming call as the fix.
359
- *
360
- * The stop reason is still read BEFORE the answer, for the reason it always was: a refusal
361
- * carries empty or partial content and parsing it first reports a schema disagreement that is not
362
- * one. Truncation is the same call it is on the non-streaming path.
363
- */
364
- async function streamedAnswer<
365
- TInput extends StandardSchemaV1,
366
- TOutput extends StandardSchemaV1,
367
- V extends PromptVars,
368
- >(
369
- def: LlmDef<TInput, TOutput, V>,
370
- name: string,
371
- gateway: Gateway,
372
- request: GenerateRequest,
373
- sink: LlmSink,
374
- span: Span,
375
- meter: RunMeter,
376
- ): Promise<InferOutput<TOutput>> {
377
- const { output } = def;
378
- const result = await meter.call(streamOneTurn(gateway, request, sink));
379
- span.setAttributes({ 'llm.attempts': 1, ...answerAttributes(result) });
380
- await observeCall(def, meter, result, 1, undefined);
381
- if (result.stopReason === 'refusal') {
382
- throw new LlmRefusedError({
383
- prompt: name,
384
- model: result.model,
385
- alternative: moreCapableThan(result.model, request),
386
- category: result.stopDetails?.category,
387
- explanation: result.stopDetails?.explanation,
388
- });
389
- }
390
- if (isTruncated(result.stopReason)) {
391
- throw new LlmTruncatedError({ prompt: name, maxTokens: request.maxTokens });
392
- }
393
- // Prose, and its JSON parse when it has one. Both, because a stream carries no `respond` tool
394
- // to tell them apart: `output: t.string` is satisfied by the text itself, and an object schema
395
- // by what the text parses to — trying only one of the two makes a legal declaration unusable.
396
- const parsed = await accept(output, parseJsonish(result.text));
397
- if (parsed !== undefined) return parsed.value;
398
- const fallback = await validateAsync(output, result.text);
399
- if (fallback.issues === undefined) return fallback.value;
400
- throw new LlmStreamInvalidError({
401
- prompt: name,
402
- issues: formatIssues(fallback.issues).join('; '),
403
- });
404
- }
405
-
406
- /** One provider call, reported to the declaration's `onCall`. Awaited; a throw fails the call. */
407
- async function observeCall<
408
- TInput extends StandardSchemaV1,
409
- TOutput extends StandardSchemaV1,
410
- V extends PromptVars,
411
- >(
412
- def: LlmDef<TInput, TOutput, V>,
413
- meter: RunMeter,
414
- result: GenerateResult,
415
- attempt: number,
416
- repairOf: readonly string[] | undefined,
417
- ): Promise<void> {
418
- if (def.onCall === undefined) return;
419
- await def.onCall({
420
- attempt,
421
- model: result.model,
422
- provider: result.provider,
423
- stopReason: result.stopReason,
424
- usage: result.usage,
425
- cost: result.cost,
426
- ...(result.costExact === undefined ? {} : { costExact: result.costExact }),
427
- ...(repairOf === undefined ? {} : { repairOf }),
428
- run: runTotal(result.model, undefined, meter.total()),
429
- });
430
- }
431
-
432
- /** `undefined` for "does not fit". Wrapped so a legitimately falsy value is still a hit. */
433
- async function accept<TOutput extends StandardSchemaV1>(
434
- schema: TOutput,
435
- value: unknown,
436
- ): Promise<{ readonly value: InferOutput<TOutput> } | undefined> {
437
- if (value === undefined) return undefined;
438
- const parsed = await validateAsync(schema, value);
439
- return parsed.issues === undefined ? { value: parsed.value } : undefined;
326
+ throw new LlmOutputInvalidError({
327
+ prompt: name,
328
+ attempts: ATTEMPTS,
329
+ issues,
330
+ ...(rejected === undefined ? {} : { list: rejected }),
331
+ });
332
+ };
333
+ return withBudget(ledger, () =>
334
+ answer()
335
+ .then(
336
+ (value) => {
337
+ report.settle({ cacheHit: false });
338
+ return value;
339
+ },
340
+ (error: unknown) => {
341
+ const billed = meter.fail(error);
342
+ report.settle({ cacheHit: false, error: billed });
343
+ throw billed;
344
+ },
345
+ )
346
+ .finally(reportSampling),
347
+ );
348
+ },
349
+ { kind: 'client' },
350
+ );
440
351
  }
441
352
 
442
353
  function repair(issues: string): AiMessage {
@@ -18,7 +18,8 @@ import { resolveModel } from './model-resolve';
18
18
  import type { ModelId } from './models';
19
19
  import { chatCompletionBody, sessionHeaders } from './openai-body';
20
20
  import type { ChatAnswer } from './openai-wire';
21
- import { ChatCompletionStream, parseChatCompletion } from './openai-wire';
21
+ import { parseChatCompletion } from './openai-wire';
22
+ import { ChatCompletionStream } from './openai-wire-stream';
22
23
  import type { ProviderCacheRow, ProviderPromptCache } from './prompt-cache';
23
24
  import { promptCacheRule } from './prompt-cache';
24
25
  import { screenCacheRows } from './prompt-cache-rows';