@ultimat3/ai 27.10.0 → 27.10.2

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ultimat3/ai",
3
- "version": "27.10.0",
3
+ "version": "27.10.2",
4
4
  "description": "LLM gateway, versioned prompts, evals as tests, embeddings, hybrid vector search, RAG",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -32,15 +32,15 @@
32
32
  "test": "bun test"
33
33
  },
34
34
  "dependencies": {
35
- "@ultimat3/action": "27.10.0",
36
- "@ultimat3/cache": "27.10.0",
37
- "@ultimat3/core": "27.10.0",
38
- "@ultimat3/db": "27.10.0",
39
- "@ultimat3/http": "27.10.0",
40
- "@ultimat3/jobs": "27.10.0",
41
- "@ultimat3/money": "27.10.0",
42
- "@ultimat3/policy": "27.10.0",
43
- "@ultimat3/schema": "27.10.0",
44
- "@ultimat3/time": "27.10.0"
35
+ "@ultimat3/action": "27.10.2",
36
+ "@ultimat3/cache": "27.10.2",
37
+ "@ultimat3/core": "27.10.2",
38
+ "@ultimat3/db": "27.10.2",
39
+ "@ultimat3/http": "27.10.2",
40
+ "@ultimat3/jobs": "27.10.2",
41
+ "@ultimat3/money": "27.10.2",
42
+ "@ultimat3/policy": "27.10.2",
43
+ "@ultimat3/schema": "27.10.2",
44
+ "@ultimat3/time": "27.10.2"
45
45
  }
46
46
  }
package/src/agent.ts CHANGED
@@ -60,7 +60,7 @@ import { RESPOND, respondFor } from './respond';
60
60
  import { runMeter } from './run-meter';
61
61
  import { aiGateway, aiRedactor, installedGateway } from './runtime';
62
62
  import type { Sampling } from './sampling';
63
- import { ignoredSampling, screenSampling } from './sampling';
63
+ import { ignoredSamplingAttribute, screenSampling } from './sampling';
64
64
  import type { AgentTool, LlmTool, LlmToolResult, ProjectableAction } from './tools';
65
65
  import { asProjectableAction, isOfferedTool, runLlmToolCall, toLlmTools, toolLabel } from './tools';
66
66
 
@@ -196,12 +196,15 @@ async function run<
196
196
  'agent.max_turns': maxTurns,
197
197
  'llm.redacted': rendered !== rawPrompt || !sameSystem(system, rawSystem),
198
198
  'llm.prompt_cache': promptCache !== undefined,
199
- 'llm.sampling.ignored': ignoredSampling(
200
- model,
201
- bounds.sampling,
202
- installedGateway()?.samplingWires?.(model),
203
- ).join(','),
204
199
  });
200
+ // Read when the run ends, not here: the gateway may be what registers the model's row.
201
+ const reportSampling = (): void => {
202
+ span.setAttributes(
203
+ ignoredSamplingAttribute(model, bounds.sampling, () =>
204
+ installedGateway()?.samplingWires?.(model),
205
+ ),
206
+ );
207
+ };
205
208
 
206
209
  // Rooted in the GATEWAY's own ceilings when no scope is open — an empty root ignored them.
207
210
  const gateway = aiGateway(name);
@@ -371,18 +374,21 @@ async function run<
371
374
  throw new AgentMaxTurnsError({ agent: name, turns: maxTurns, calls });
372
375
  };
373
376
  return withBudget(ledger, () =>
374
- meter.within(loop).catch((error: unknown) => {
375
- const failed = meter.fail(error);
376
- // A failed `.run()` / `.continue()` leaves a handle too: the transcript up to the request
377
- // the failed turn was answering, with `turns` and `usage` counting what this call was
378
- // billed. Without it a retry resumes from the OLD handle, which never heard of that spend
379
- // — six truncated continuations billed six times against a ceiling that saw none of them.
380
- if (session !== undefined && at.messages.length > 0) {
381
- const { usage } = runTotal(model, prior?.usage, meter.billed());
382
- withTranscript(failed, sealTranscript(binding, { ...at, usage }));
383
- }
384
- throw failed;
385
- }),
377
+ meter
378
+ .within(loop)
379
+ .catch((error: unknown) => {
380
+ const failed = meter.fail(error);
381
+ // A failed `.run()` / `.continue()` leaves a handle too: the transcript up to the request
382
+ // the failed turn was answering, with `turns` and `usage` counting what this call was
383
+ // billed. Without it a retry resumes from the OLD handle, which never heard of that spend
384
+ // — six truncated continuations billed six times against a ceiling that saw none of them.
385
+ if (session !== undefined && at.messages.length > 0) {
386
+ const { usage } = runTotal(model, prior?.usage, meter.billed());
387
+ withTranscript(failed, sealTranscript(binding, { ...at, usage }));
388
+ }
389
+ throw failed;
390
+ })
391
+ .finally(reportSampling),
386
392
  );
387
393
  });
388
394
  }
@@ -3,7 +3,7 @@
3
3
  // it withholds goes to the log as content-free facts (code, name, frames) — never its text.
4
4
 
5
5
  import type { Logger } from '@ultimat3/core';
6
- import { hasPublicCause, isUltimateError, stringField } from '@ultimat3/core';
6
+ import { failureStack, hasPublicCause, isUltimateError, stringField } from '@ultimat3/core';
7
7
  import { declaredStatusFor, statusFor } from '@ultimat3/http';
8
8
 
9
9
  /**
@@ -91,7 +91,8 @@ export function discloseOwnFailure(
91
91
  const FRAME = /^\s+at\s/;
92
92
 
93
93
  function framesOf(error: unknown): string | null {
94
- const stack = stringField(error, 'stack');
94
+ // The header restored first: a lost one (`Error`, #773) made `skip` below eat real frames.
95
+ const stack = failureStack(error);
95
96
  if (stack === undefined) return null;
96
97
  const lines = stack.split('\n');
97
98
  // Skip to the first frame line that FOLLOWS the message: a message may itself contain text
package/src/llm.ts CHANGED
@@ -50,7 +50,7 @@ import type { RunMeter } from './run-meter';
50
50
  import { runMeter } from './run-meter';
51
51
  import { aiGateway, aiRedactor, installedGateway } from './runtime';
52
52
  import type { Sampling } from './sampling';
53
- import { ignoredSampling, screenSampling } from './sampling';
53
+ import { ignoredSamplingAttribute, screenSampling } from './sampling';
54
54
 
55
55
  /** Two attempts total: the answer, then one repair turn. See `LlmOutputInvalidError`. */
56
56
  const ATTEMPTS = 2;
@@ -182,12 +182,13 @@ async function generate<
182
182
  // an audit asks evidence for, and a redactor that silently stopped matching looks identical
183
183
  // to one that had nothing to remove until this attribute separates them.
184
184
  'llm.redacted': redacted,
185
- 'llm.sampling.ignored': ignoredSampling(
186
- model,
187
- sampling,
188
- installedGateway()?.samplingWires?.(model),
189
- ).join(','),
190
185
  });
186
+ // Read when the call ends, not here: the gateway may be what registers the model's row.
187
+ const reportSampling = (): void => {
188
+ span.setAttributes(
189
+ ignoredSamplingAttribute(model, sampling, () => installedGateway()?.samplingWires?.(model)),
190
+ );
191
+ };
191
192
 
192
193
  // A cached answer is still data of unknown provenance, so it goes through the schema like
193
194
  // any other. One that no longer fits — the schema moved under it — is a miss, not a
@@ -201,7 +202,10 @@ async function generate<
201
202
  });
202
203
  const hit = await accept(def.output, await cache?.lookup());
203
204
  span.setAttribute('llm.cache.hit', hit !== undefined);
204
- if (hit !== undefined) return hit.value;
205
+ if (hit !== undefined) {
206
+ reportSampling();
207
+ return hit.value;
208
+ }
205
209
 
206
210
  // No `tools` yet: the `respond` projection belongs to the non-streaming path alone. A tool
207
211
  // call is emitted whole, so forcing the answer through one leaves a stream with nothing to
@@ -292,9 +296,11 @@ async function generate<
292
296
  });
293
297
  };
294
298
  return withBudget(ledger, () =>
295
- answer().catch((error: unknown) => {
296
- throw meter.fail(error);
297
- }),
299
+ answer()
300
+ .catch((error: unknown) => {
301
+ throw meter.fail(error);
302
+ })
303
+ .finally(reportSampling),
298
304
  );
299
305
  });
300
306
  }
package/src/sampling.ts CHANGED
@@ -11,7 +11,7 @@
11
11
 
12
12
  import { AiRequestInvalidError } from './errors';
13
13
  import type { ModelId } from './models';
14
- import { modelSpec } from './models';
14
+ import { isModelRegistered, modelSpec } from './models';
15
15
 
16
16
  export interface Sampling {
17
17
  /** 0 to 2. `0` is the most repeatable a model offers; it is not a guarantee of determinism. */
@@ -74,6 +74,8 @@ export function ignoredSampling(
74
74
  /** The wire formats of the providers serving `model`, when the gateway can say. */
75
75
  wires?: readonly SamplingWire[],
76
76
  ): readonly string[] {
77
+ // Nothing asked, nothing ignored — and no row read: a model none asked of needs none here.
78
+ if (request.temperature === undefined && request.seed === undefined) return [];
77
79
  const accepts = modelSpec(model).sampling;
78
80
  // A knob no serving wire has a word for is dropped too — `seed` on the Messages API.
79
81
  const sendable = (knob: 'temperature' | 'seed'): boolean =>
@@ -84,3 +86,28 @@ export function ignoredSampling(
84
86
  (knob) => request[knob] !== undefined && (accepts?.[knob] !== true || !sendable(knob)),
85
87
  );
86
88
  }
89
+
90
+ /**
91
+ * The `llm.sampling.ignored` span attribute, set when a run ENDS — answered or failed — never at
92
+ * its start. The catalogue is the app's, and an app may register it when its gateway is first used
93
+ * (a gateway built lazily from the env): read at the top of `agent()` / `llm()`, before the gateway
94
+ * was asked anything, the row was not there yet and a REPORT refused the call `X_AI_MODEL_UNKNOWN`.
95
+ * A model still unregistered by then has no row to report against; the call's own failure says so.
96
+ */
97
+ export function ignoredSamplingAttribute(
98
+ model: ModelId,
99
+ request: Sampling,
100
+ wires: () => readonly SamplingWire[] | undefined,
101
+ ): Record<'llm.sampling.ignored', string> | Record<string, never> {
102
+ if (request.temperature === undefined && request.seed === undefined) {
103
+ return { 'llm.sampling.ignored': '' };
104
+ }
105
+ if (!isModelRegistered(model)) return {};
106
+ // A report never fails the call it describes: an installed gateway may throw from
107
+ // `samplingWires`, and the attribute is then left out rather than replacing the call's result.
108
+ try {
109
+ return { 'llm.sampling.ignored': ignoredSampling(model, request, wires()).join(',') };
110
+ } catch {
111
+ return {};
112
+ }
113
+ }