@ultimat3/ai 1.1.0 → 2.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.
@@ -0,0 +1,35 @@
1
+ /**
2
+ * The eval attached to `fixLinePrompt`. Cases live here rather than in the test file, matching
3
+ * every app's own prompts — a fixture-driven, deterministic `exact` scorer, no judge, because
4
+ * this is a proof of the eval mechanism itself and a proof that can drift is not one.
5
+ */
6
+
7
+ import { defineEval } from './evals';
8
+ import { fixLinePrompt } from './fix-line';
9
+ import { exact } from './scorers';
10
+
11
+ export const fixLineCases = [
12
+ {
13
+ name: 'runnable/x-command',
14
+ vars: { fixLine: 'bun run scripts/verify.ts --only lint,boundaries' },
15
+ expected: 'runnable',
16
+ },
17
+ { name: 'runnable/cli-flag', vars: { fixLine: 'x db migrate' }, expected: 'runnable' },
18
+ {
19
+ name: 'vague/check',
20
+ vars: { fixLine: 'check the configuration and try again' },
21
+ expected: 'vague',
22
+ },
23
+ { name: 'vague/see-docs', vars: { fixLine: 'see the docs for details' }, expected: 'vague' },
24
+ ];
25
+
26
+ export const fixLineEval = defineEval({
27
+ name: 'ai.fix-line-runnable',
28
+ prompt: fixLinePrompt,
29
+ // The gate is the drop from this recorded score, never an absolute number: models drift,
30
+ // prompts should not. Accepting a new number is a diff in the committed baseline file.
31
+ baseline: import.meta.resolve('./fix-line.v1.baseline.json'),
32
+ tolerance: 0.05,
33
+ scorers: [exact],
34
+ cases: fixLineCases,
35
+ });
@@ -0,0 +1,27 @@
1
+ /**
2
+ * The prompt behind `fix-line` — `@ultimat3/ai`'s own dogfood eval, and the package's first
3
+ * framework-level `*.eval.test.ts`. Not an app feature: every app that declares a prompt is
4
+ * required to pair it with an eval and a committed baseline (`defineEval`, `X_EVAL_MISSING`,
5
+ * `X_EVAL_BASELINE_MISSING`), and this proves that whole convention actually catches a
6
+ * regression from inside the package that owns it — through the real opt-in suite `x verify`'s
7
+ * `eval` step runs, not only through `evals.test.ts`'s unit fixtures against a temp-dir baseline.
8
+ *
9
+ * The task is small on purpose: classify whether an error's `fix:` line is a runnable command —
10
+ * axiom 4, "errors are instructions" — or vague guidance ("check the config", "see the docs").
11
+ */
12
+
13
+ import { definePrompt } from './prompt';
14
+
15
+ export const fixLinePrompt = definePrompt<{ fixLine: string }>({
16
+ id: 'ai.fix-line-runnable',
17
+ version: '1',
18
+ template:
19
+ 'Reply with exactly one word: "runnable" if the fix line below names a command to run, or ' +
20
+ '"vague" if it only describes what to do without naming one.\nFix line: {{fixLine}}',
21
+ input: {
22
+ type: 'object',
23
+ properties: { fixLine: { type: 'string' } },
24
+ required: ['fixLine'],
25
+ },
26
+ output: { type: 'string', enum: ['runnable', 'vague'] },
27
+ });
@@ -0,0 +1,12 @@
1
+ {
2
+ "eval": "ai.fix-line-runnable",
3
+ "prompt": "ai.fix-line-runnable@1",
4
+ "promptHash": "6b788c934d335044043d13c71c1c7b49",
5
+ "score": 1,
6
+ "cases": {
7
+ "runnable/cli-flag": 1,
8
+ "runnable/x-command": 1,
9
+ "vague/check": 1,
10
+ "vague/see-docs": 1
11
+ }
12
+ }
package/src/gateway.ts CHANGED
@@ -98,10 +98,20 @@ class GatewayImpl implements Gateway {
98
98
  // cheap-in-tokens call on an expensive model is still a cost cap the app declared.
99
99
  // `record` below replaces the estimate with the provider's real counts.
100
100
  const ledger = currentBudget();
101
- await ledger?.reserve(estimateSpend(resolved));
102
-
103
- const result = await this.attempt(model, (provider) => provider.generate(resolved));
104
- await ledger?.record(result.usage, result.cost);
101
+ // The estimate is DEBITED here, not merely checked: three concurrent calls under one ledger
102
+ // all read the same `spent()` otherwise, all pass, and all three record against a ceiling
103
+ // only one of them fitted.
104
+ const reservation = await ledger?.reserve(estimateSpend(resolved));
105
+
106
+ let result: GenerateResult;
107
+ try {
108
+ result = await this.attempt(model, (provider) => provider.generate(resolved));
109
+ } catch (error) {
110
+ // A call that never landed must not go on holding its reservation.
111
+ await ledger?.release(reservation);
112
+ throw error;
113
+ }
114
+ await ledger?.record(result.usage, result.cost, reservation);
105
115
  // A refusal is not an answer, so it is not cached. Storing one would keep serving a decision
106
116
  // the classifier might not make twice, long after the prompt that provoked it was fixed.
107
117
  if (result.stopReason !== 'refusal') {
@@ -114,14 +124,26 @@ class GatewayImpl implements Gateway {
114
124
  const model = request.model ?? this.config.defaultModel ?? DEFAULT_MODEL;
115
125
  const resolved: GenerateRequest = { ...request, model };
116
126
  const ledger = currentBudget();
117
- await ledger?.reserve(estimateSpend(resolved));
127
+ const reservation = await ledger?.reserve(estimateSpend(resolved));
118
128
 
119
129
  // A stream is not retried mid-flight: the consumer has already seen tokens, and
120
130
  // replaying from the top would duplicate them. Only the handshake retries.
121
131
  const provider = this.providerFor(model);
122
- for await (const chunk of provider.stream(resolved)) {
123
- if (chunk.type === 'done') await ledger?.record(chunk.result.usage, chunk.result.cost);
124
- yield chunk;
132
+ let settled = false;
133
+ try {
134
+ for await (const chunk of provider.stream(resolved)) {
135
+ if (chunk.type !== 'done') {
136
+ yield chunk;
137
+ continue;
138
+ }
139
+ settled = true;
140
+ await ledger?.record(chunk.result.usage, chunk.result.cost, reservation);
141
+ yield { type: 'done', result: { ...chunk.result, provider: provider.name } };
142
+ }
143
+ } finally {
144
+ // A stream that threw, or that its consumer abandoned, never reached `done` — so nothing
145
+ // reconciled the reservation and it would hold the ceiling for the rest of the window.
146
+ if (!settled) await ledger?.release(reservation);
125
147
  }
126
148
  }
127
149
 
@@ -140,8 +162,16 @@ class GatewayImpl implements Gateway {
140
162
  * Try every provider that serves `model`, retrying each on a retryable failure with
141
163
  * exponential backoff plus full jitter (jitter matters: synchronised retries from N
142
164
  * workers reproduce the rate limit they are backing off from).
165
+ *
166
+ * Fallback here is across PROVIDERS serving one model, never across models — a silent model
167
+ * swap changes what answered, what it cost and which eval baseline the answer belongs to. The
168
+ * provider that did answer is stamped onto the result, so the fallback that DOES exist reaches
169
+ * the span instead of being invisible.
143
170
  */
144
- private async attempt<T>(model: ModelId, call: (provider: Provider) => Promise<T>): Promise<T> {
171
+ private async attempt(
172
+ model: ModelId,
173
+ call: (provider: Provider) => Promise<GenerateResult>,
174
+ ): Promise<GenerateResult> {
145
175
  const candidates = this.config.providers.filter((p) => p.models.includes(model));
146
176
  const failures: string[] = [];
147
177
  if (candidates.length === 0) {
@@ -151,7 +181,7 @@ class GatewayImpl implements Gateway {
151
181
  for (const provider of candidates) {
152
182
  for (let attempt = 1; attempt <= this.retry.attempts; attempt += 1) {
153
183
  try {
154
- return await call(provider);
184
+ return { ...(await call(provider)), provider: provider.name };
155
185
  } catch (error) {
156
186
  failures.push(`${provider.name}#${attempt}: ${messageOf(error)}`);
157
187
  if (!isRetryable(error) || attempt === this.retry.attempts) break;
package/src/index.ts CHANGED
@@ -4,6 +4,8 @@
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, AgentVarsArgs } from './agent';
8
+ export { agent } from './agent';
7
9
  export type {
8
10
  BudgetLedgerInput,
9
11
  BudgetLimits,
@@ -30,24 +32,24 @@ export {
30
32
  } from './embeddings';
31
33
  export type { AiErrorCode } from './errors';
32
34
  export {
35
+ AgentMaxTurnsError,
36
+ AgentToolUnexposedError,
33
37
  AI_ERROR_CODES,
34
38
  AI_ERROR_TITLES,
35
39
  AiBudgetExceededError,
36
40
  AiGatewayMissingError,
37
41
  AiKeyMissingError,
42
+ AiModelUnknownError,
38
43
  AiPromptRenderError,
44
+ AiPromptSecretError,
39
45
  AiPromptVersionError,
40
46
  AiProviderUnavailableError,
41
47
  AiRequestInvalidError,
42
48
  AiTransportError,
43
49
  EmbedderDimMismatchError,
44
- EvalBaselineInvalidError,
45
- EvalBaselineMissingError,
46
- EvalMissingError,
47
- EvalRecordingError,
48
- EvalThresholdError,
49
50
  LlmOutputInvalidError,
50
51
  LlmRefusedError,
52
+ LlmStreamInvalidError,
51
53
  LlmTruncatedError,
52
54
  VectorDimMismatchError,
53
55
  VectorScopeWidenedError,
@@ -63,6 +65,13 @@ export {
63
65
  regressionsAgainst,
64
66
  writeBaseline,
65
67
  } from './eval-baseline';
68
+ export {
69
+ EvalBaselineInvalidError,
70
+ EvalBaselineMissingError,
71
+ EvalMissingError,
72
+ EvalRecordingError,
73
+ EvalThresholdError,
74
+ } from './eval-errors';
66
75
  export type {
67
76
  CaseResult,
68
77
  DefineEvalInput,
@@ -81,6 +90,7 @@ export {
81
90
  export type { CreateGatewayInput, Gateway, GatewayCache, RetryPolicy } from './gateway';
82
91
  export { backoffMs, cacheKeyFor, createGateway, DEFAULT_RETRY, isRetryable } from './gateway';
83
92
  export type {
93
+ LlmAction,
84
94
  LlmBudget,
85
95
  LlmCache,
86
96
  LlmDef,
@@ -88,8 +98,25 @@ export type {
88
98
  LlmVarsArgs,
89
99
  } from './llm';
90
100
  export { llm } from './llm';
101
+ export type { LlmStreamChunk } from './llm-stream';
91
102
  export type { Effort, ModelId, ModelReasoning, ModelSpec, ThinkingMode } from './models';
92
- export { DEFAULT_MODEL, EFFORTS, MODEL_IDS, MODELS, reasoningBody } from './models';
103
+ export {
104
+ ANTHROPIC_MODEL_IDS,
105
+ assertModel,
106
+ DEFAULT_MODEL,
107
+ EFFORTS,
108
+ isModelRegistered,
109
+ modelIds,
110
+ modelSpec,
111
+ moreCapableThan,
112
+ reasoningBody,
113
+ registeredModels,
114
+ registerModel,
115
+ resetModels,
116
+ } from './models';
117
+ export { OPENAI_MODEL_IDS, registerOpenAiModels } from './openai-models';
118
+ export type { OpenAiProviderInput } from './openai-provider';
119
+ export { openAiProvider } from './openai-provider';
93
120
  export type { PgVectorStoreInput } from './pg-vector';
94
121
  export { PgVectorStore } from './pg-vector';
95
122
  export type {
@@ -118,6 +145,7 @@ export {
118
145
  resetPrompts,
119
146
  } from './prompt';
120
147
  export type {
148
+ AiContentBlock,
121
149
  AiMessage,
122
150
  AnthropicProviderInput,
123
151
  EchoProviderInput,
@@ -137,6 +165,7 @@ export {
137
165
  estimateInputTokens,
138
166
  estimateTextTokens,
139
167
  estimateTokens,
168
+ messageText,
140
169
  parseMessage,
141
170
  requiresStreaming,
142
171
  STREAM_ONLY_MAX_TOKENS,
@@ -150,10 +179,18 @@ export type {
150
179
  RetrieveInput,
151
180
  } from './rag';
152
181
  export { assembleContext, chunk, indexDocument, passthroughReranker, retrieve } from './rag';
182
+ export { assertNoSecrets } from './redaction';
153
183
  export type { RemoteEmbedderInput } from './remote-embedder';
154
184
  export { RemoteEmbedder } from './remote-embedder';
155
- export type { AiRuntimeInput } from './runtime';
156
- export { aiEmbedder, aiGateway, configureAi, resetAiRuntime, semanticCacheFor } from './runtime';
185
+ export type { AiRuntimeInput, Redactor } from './runtime';
186
+ export {
187
+ aiEmbedder,
188
+ aiGateway,
189
+ aiRedactor,
190
+ configureAi,
191
+ resetAiRuntime,
192
+ semanticCacheFor,
193
+ } from './runtime';
157
194
  export type { Scorer } from './scorers';
158
195
  export {
159
196
  contains,
@@ -0,0 +1,171 @@
1
+ /**
2
+ * The streaming half of `llm()`. Same action, same policy, same budget — one transport swapped.
3
+ *
4
+ * `.stream()` re-enters the action through its ordinary call path, marking the invocation with an
5
+ * ambient sink; `llm.ts` sees the sink and drives `gateway.stream` instead of `gateway.generate`.
6
+ * That is why the policy, the input parse, the audit record, the rate limit, the semantic cache,
7
+ * the span and the manifest row all still apply: there is no second execution path to keep in
8
+ * step, only a second way for the answer to arrive.
9
+ *
10
+ * Two questions a streamed model call forces, both answered here:
11
+ *
12
+ * 1. OUTPUT SCHEMA. A schema cannot be checked until the last token has landed, so a stream
13
+ * yields UNVALIDATED text and one final `done` carrying the value that DID satisfy `output`.
14
+ * There is no repair turn: the consumer has already read the tokens, and replaying a second
15
+ * answer over the top is two answers to one question. One attempt, then
16
+ * `X_LLM_STREAM_INVALID`, whose fix is the non-streaming call that can repair.
17
+ * 2. BUDGET. Unchanged, and still reserved before the provider is touched: `Gateway.stream`
18
+ * debits the worst-case estimate on the first pull and reconciles it against real usage at
19
+ * `done`, releasing it in a `finally` when a stream throws or is abandoned. The whole stream
20
+ * is driven INSIDE the action's handler, so the reservation and its reconciliation sit on one
21
+ * async chain — abandoning the iterator stops delivery, never the accounting.
22
+ */
23
+
24
+ import { AsyncLocalStorage } from 'node:async_hooks';
25
+ import { AiTransportError } from './errors';
26
+ import type { Gateway } from './gateway';
27
+ import type { GenerateRequest, GenerateResult } from './provider';
28
+
29
+ /**
30
+ * One increment of a streamed answer. `thinking` is a separate kind on purpose — the wire layer
31
+ * refuses to fold reasoning into `text`, and a consumer concatenating every chunk must not end up
32
+ * shipping it to the user.
33
+ */
34
+ export type LlmStreamChunk<T> =
35
+ | { readonly type: 'text'; readonly text: string }
36
+ | { readonly type: 'thinking'; readonly text: string }
37
+ | { readonly type: 'done'; readonly value: T };
38
+
39
+ type Resolver = () => void;
40
+
41
+ /**
42
+ * The buffer between the handler pushing chunks and the caller pulling them. Unbounded: an LLM
43
+ * answer is bounded by `maxTokens` and a slow reader must never stall a call whose budget
44
+ * reservation is already open.
45
+ */
46
+ export class LlmSink {
47
+ private readonly queue: LlmStreamChunk<unknown>[] = [];
48
+ private wake: Resolver | undefined;
49
+ private ended = false;
50
+ private failure: unknown;
51
+ private failed = false;
52
+ private abandoned = false;
53
+
54
+ emit(chunk: LlmStreamChunk<unknown>): void {
55
+ // A consumer that stopped reading gets nothing more, but the call it authorised runs to
56
+ // completion — half a reconciliation holds a budget ceiling for the rest of the window.
57
+ if (this.abandoned) return;
58
+ this.queue.push(chunk);
59
+ this.release();
60
+ }
61
+
62
+ finish(value: unknown): void {
63
+ this.emit({ type: 'done', value });
64
+ this.ended = true;
65
+ this.release();
66
+ }
67
+
68
+ fail(error: unknown): void {
69
+ this.failure = error;
70
+ this.failed = true;
71
+ this.ended = true;
72
+ this.release();
73
+ }
74
+
75
+ close(): void {
76
+ this.abandoned = true;
77
+ this.queue.length = 0;
78
+ this.release();
79
+ }
80
+
81
+ async *drain(): AsyncGenerator<LlmStreamChunk<unknown>> {
82
+ while (true) {
83
+ const next = this.queue.shift();
84
+ if (next !== undefined) {
85
+ yield next;
86
+ continue;
87
+ }
88
+ // The original throwable, rethrown rather than wrapped: it is already an `UltimateError`
89
+ // with the code, the cause and the fix the caller needs, and re-boxing it loses all three.
90
+ if (this.failed) throw this.failure;
91
+ if (this.ended || this.abandoned) return;
92
+ await new Promise<void>((resolve) => {
93
+ this.wake = resolve;
94
+ });
95
+ }
96
+ }
97
+
98
+ private release(): void {
99
+ const waiter = this.wake;
100
+ this.wake = undefined;
101
+ waiter?.();
102
+ }
103
+ }
104
+
105
+ const sinks = new AsyncLocalStorage<LlmSink>();
106
+
107
+ /** Mark everything `fn` awaits as a streamed invocation. */
108
+ export function withLlmSink<T>(sink: LlmSink, fn: () => Promise<T>): Promise<T> {
109
+ return sinks.run(sink, fn);
110
+ }
111
+
112
+ /** The sink of the streamed invocation this call belongs to, or `undefined` for a plain one. */
113
+ export function currentLlmSink(): LlmSink | undefined {
114
+ return sinks.getStore();
115
+ }
116
+
117
+ /**
118
+ * Turn one invocation into an iterable of chunks. LAZY, like `Gateway.stream`: nothing is
119
+ * authorised, budgeted or sent until the first pull, so a `.stream()` handed to a consumer that
120
+ * never reads it spends nothing and denies nobody.
121
+ */
122
+ export function llmStream<T>(run: (sink: LlmSink) => Promise<T>): AsyncIterable<LlmStreamChunk<T>> {
123
+ return {
124
+ async *[Symbol.asyncIterator](): AsyncGenerator<LlmStreamChunk<T>> {
125
+ const sink = new LlmSink();
126
+ // Never rejects — every outcome lands on the sink, which is where the consumer looks.
127
+ void run(sink).then(
128
+ (value) => {
129
+ sink.finish(value);
130
+ },
131
+ (error: unknown) => {
132
+ sink.fail(error);
133
+ },
134
+ );
135
+ try {
136
+ for await (const chunk of sink.drain()) yield chunk as LlmStreamChunk<T>;
137
+ } finally {
138
+ sink.close();
139
+ }
140
+ },
141
+ };
142
+ }
143
+
144
+ /**
145
+ * Drive one streamed turn: forward every increment to the consumer, keep the assembled result.
146
+ *
147
+ * A `tool-call` chunk cannot arrive here — the streaming path offers no tools, precisely so there
148
+ * ARE text deltas to stream — and is dropped rather than thrown on, because a provider that sends
149
+ * one has not broken the answer the `done` chunk carries.
150
+ */
151
+ export async function streamOneTurn(
152
+ gateway: Gateway,
153
+ request: GenerateRequest,
154
+ sink: LlmSink,
155
+ ): Promise<GenerateResult> {
156
+ let assembled: GenerateResult | undefined;
157
+ for await (const chunk of gateway.stream(request)) {
158
+ if (chunk.type === 'text') sink.emit({ type: 'text', text: chunk.text });
159
+ else if (chunk.type === 'thinking') sink.emit({ type: 'thinking', text: chunk.text });
160
+ else if (chunk.type === 'done') assembled = chunk.result;
161
+ }
162
+ if (assembled === undefined) {
163
+ // Not "an empty answer": the transport ended without the frame that carries usage and stop
164
+ // reason, so nothing downstream could tell a complete answer from a cut one.
165
+ throw new AiTransportError({
166
+ provider: 'gateway',
167
+ detail: 'the stream ended without a done chunk, so the answer and its usage are unknown',
168
+ });
169
+ }
170
+ return assembled;
171
+ }