@ultimat3/ai 1.2.0 → 3.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.
- package/CLAUDE.md +521 -0
- package/README.md +367 -6
- package/package.json +11 -9
- package/src/agent-facts.ts +70 -0
- package/src/agent-job.ts +97 -0
- package/src/agent-transcript.ts +94 -0
- package/src/agent.ts +396 -0
- package/src/budget.ts +98 -11
- package/src/error-body.ts +44 -0
- package/src/errors.ts +144 -96
- package/src/eval-baseline.ts +1 -1
- package/src/eval-errors.ts +98 -0
- package/src/evals.ts +1 -1
- package/src/fix-line.evals.ts +35 -0
- package/src/fix-line.ts +27 -0
- package/src/fix-line.v1.baseline.json +12 -0
- package/src/gateway.ts +57 -19
- package/src/hive-errors.ts +30 -0
- package/src/hive-pool.ts +90 -0
- package/src/hive-result.ts +96 -0
- package/src/hive.ts +177 -0
- package/src/index.ts +55 -9
- package/src/llm-stream.ts +171 -0
- package/src/llm.ts +203 -26
- package/src/models.ts +186 -49
- package/src/openai-body.ts +96 -0
- package/src/openai-messages.ts +174 -0
- package/src/openai-models.ts +84 -0
- package/src/openai-provider.ts +274 -0
- package/src/openai-wire.ts +339 -0
- package/src/pg-vector-sql.ts +5 -1
- package/src/pg-vector.ts +2 -1
- package/src/prompt.ts +1 -1
- package/src/provider.ts +112 -38
- package/src/rag.ts +27 -3
- package/src/redaction.ts +22 -0
- package/src/remote-embedder.ts +53 -6
- package/src/runtime.ts +29 -0
- package/src/tools.ts +107 -11
- package/src/vector.ts +0 -0
- package/src/wire.ts +41 -11
|
@@ -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
|
+
}
|
package/src/llm.ts
CHANGED
|
@@ -11,29 +11,50 @@
|
|
|
11
11
|
* What the factory adds is the model half: the prompt is rendered from the parsed input, the
|
|
12
12
|
* `output` schema is projected into a tool the model must answer through, a per-call budget is
|
|
13
13
|
* reserved before a token is spent, and a near-duplicate prompt hits the semantic cache.
|
|
14
|
+
*
|
|
15
|
+
* `.stream()` is the same action over a different transport, and it is here rather than beside
|
|
16
|
+
* the gateway for one reason: everything that makes `llm()` worth using — policy, input parse,
|
|
17
|
+
* budget scope, semantic cache, span, `.tool()` — is lost the moment a feature has to reach past
|
|
18
|
+
* it to `aiGateway()` for tokens on a screen. `./llm-stream.ts` holds the plumbing and states the
|
|
19
|
+
* two decisions a stream forces (no repair turn, budget reserved exactly as before); what a
|
|
20
|
+
* streamed answer must satisfy is decided in this file, next to the non-streaming version of the
|
|
21
|
+
* same rules.
|
|
14
22
|
*/
|
|
15
23
|
|
|
16
|
-
import type { Action, ActionMcp, ActionPolicy } from '@ultimat3/action';
|
|
24
|
+
import type { Action, ActionMcp, ActionPolicy, InvokeOptions } from '@ultimat3/action';
|
|
17
25
|
import { action } from '@ultimat3/action';
|
|
18
|
-
import type { Ctx } from '@ultimat3/core';
|
|
26
|
+
import type { Ctx, Span, SpanAttributes } from '@ultimat3/core';
|
|
19
27
|
import { withSpan } from '@ultimat3/core';
|
|
20
28
|
import type { Money } from '@ultimat3/money';
|
|
21
|
-
import type { InferOutput, StandardSchemaV1 } from '@ultimat3/schema';
|
|
29
|
+
import type { InferInput, InferOutput, StandardSchemaV1 } from '@ultimat3/schema';
|
|
22
30
|
import { formatIssues, toMcpInputSchema, validateAsync } from '@ultimat3/schema';
|
|
23
31
|
import { parseDuration } from '@ultimat3/time';
|
|
24
32
|
import type { BudgetLimits } from './budget';
|
|
25
33
|
import { BudgetLedger, currentBudget, withBudget } from './budget';
|
|
26
34
|
import { embedOne, fnv1a } from './embeddings';
|
|
27
|
-
import {
|
|
35
|
+
import {
|
|
36
|
+
LlmOutputInvalidError,
|
|
37
|
+
LlmRefusedError,
|
|
38
|
+
LlmStreamInvalidError,
|
|
39
|
+
LlmTruncatedError,
|
|
40
|
+
} from './errors';
|
|
41
|
+
import type { Gateway } from './gateway';
|
|
42
|
+
import type { LlmSink, LlmStreamChunk } from './llm-stream';
|
|
43
|
+
import { currentLlmSink, llmStream, streamOneTurn, withLlmSink } from './llm-stream';
|
|
28
44
|
import type { ModelId } from './models';
|
|
29
|
-
import { DEFAULT_MODEL,
|
|
45
|
+
import { DEFAULT_MODEL, moreCapableThan } from './models';
|
|
30
46
|
import type { Prompt, PromptVars } from './prompt';
|
|
31
47
|
import type { AiMessage, GenerateRequest, GenerateResult } from './provider';
|
|
32
|
-
import {
|
|
48
|
+
import { assertNoSecrets } from './redaction';
|
|
49
|
+
import { aiEmbedder, aiGateway, aiRedactor, semanticCacheFor } from './runtime';
|
|
33
50
|
import type { LlmTool } from './tools';
|
|
34
51
|
|
|
35
|
-
/**
|
|
36
|
-
|
|
52
|
+
/**
|
|
53
|
+
* The tool the model answers through. One name, so the reader never has to guess — and shared
|
|
54
|
+
* with `agent()`, which offers the app's tools alongside it and needs the same name to tell an
|
|
55
|
+
* answer from a tool call.
|
|
56
|
+
*/
|
|
57
|
+
export const RESPOND = 'respond';
|
|
37
58
|
|
|
38
59
|
/** Two attempts total: the answer, then one repair turn. See `LlmOutputInvalidError`. */
|
|
39
60
|
const ATTEMPTS = 2;
|
|
@@ -105,19 +126,73 @@ export interface LlmDef<
|
|
|
105
126
|
readonly maxTokens?: number;
|
|
106
127
|
}
|
|
107
128
|
|
|
129
|
+
/**
|
|
130
|
+
* An `action` with one extra way to be called. Every projection an action has, it has — `.tool()`,
|
|
131
|
+
* `.openapi()`, `.client()`, `.job()`, `.contract()` — plus a transport for the case an action's
|
|
132
|
+
* single return value cannot serve: text on a screen before the answer is finished.
|
|
133
|
+
*/
|
|
134
|
+
export interface LlmAction<TInput extends StandardSchemaV1, TOutput extends StandardSchemaV1>
|
|
135
|
+
extends Action<TInput, TOutput> {
|
|
136
|
+
/**
|
|
137
|
+
* The same call, delivered as it arrives. Runs the action's policy, input parse, budget scope,
|
|
138
|
+
* semantic cache and span exactly as calling it would — the invocation IS an ordinary one,
|
|
139
|
+
* marked so the model half streams. Yields `text` and `thinking` increments, then one `done`
|
|
140
|
+
* carrying the value that satisfied `output`.
|
|
141
|
+
*
|
|
142
|
+
* Lazy: nothing is authorised or sent until the first pull. A streamed call offers the model no
|
|
143
|
+
* `respond` tool — a tool call arrives whole, so forcing one leaves nothing to stream — which
|
|
144
|
+
* means the answer is prose, and its JSON parse is what a non-string `output` validates.
|
|
145
|
+
* Abandoning the iterator stops delivery, never the call: the budget reservation is reconciled
|
|
146
|
+
* by the chain that opened it.
|
|
147
|
+
*/
|
|
148
|
+
stream(
|
|
149
|
+
input: InferInput<TInput>,
|
|
150
|
+
opts?: InvokeOptions,
|
|
151
|
+
): AsyncIterable<LlmStreamChunk<InferOutput<TOutput>>>;
|
|
152
|
+
/** Narrowed: a renamed twin of a model call is still a model call, and still streams. */
|
|
153
|
+
named(name: string): LlmAction<TInput, TOutput>;
|
|
154
|
+
}
|
|
155
|
+
|
|
108
156
|
export function llm<
|
|
109
157
|
TInput extends StandardSchemaV1,
|
|
110
158
|
TOutput extends StandardSchemaV1,
|
|
111
159
|
V extends PromptVars,
|
|
112
|
-
>(def: LlmDef<TInput, TOutput, V>):
|
|
160
|
+
>(def: LlmDef<TInput, TOutput, V>): LlmAction<TInput, TOutput> {
|
|
113
161
|
const respond = respondToolFor(def.output);
|
|
114
|
-
|
|
162
|
+
const built = action<TInput, TOutput>({
|
|
115
163
|
input: def.input,
|
|
116
164
|
output: def.output,
|
|
117
165
|
policy: def.policy,
|
|
118
166
|
...(def.mcp === undefined ? {} : { mcp: def.mcp }),
|
|
119
167
|
handle: (args) => generate(def, respond, args),
|
|
120
168
|
});
|
|
169
|
+
return streamable(built);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Attach `.stream()` to an action IN PLACE, rather than wrapping it. One object is the point:
|
|
174
|
+
* `nameAction` stamps a name onto the very object an app exported, `invoke` reads the declaration
|
|
175
|
+
* off it, and a wrapper would leave a second action the registry never saw.
|
|
176
|
+
*
|
|
177
|
+
* `named()` is re-narrowed for the same reason. `action()`'s `named` builds a fresh twin, which
|
|
178
|
+
* would silently be a model call that cannot stream — so the twin is passed back through here.
|
|
179
|
+
* The original is captured first, or the override would call itself.
|
|
180
|
+
*/
|
|
181
|
+
function streamable<TInput extends StandardSchemaV1, TOutput extends StandardSchemaV1>(
|
|
182
|
+
target: Action<TInput, TOutput>,
|
|
183
|
+
): LlmAction<TInput, TOutput> {
|
|
184
|
+
const rename = target.named.bind(target);
|
|
185
|
+
const self: LlmAction<TInput, TOutput> = Object.assign(target, {
|
|
186
|
+
stream: (
|
|
187
|
+
input: InferInput<TInput>,
|
|
188
|
+
opts?: InvokeOptions,
|
|
189
|
+
): AsyncIterable<LlmStreamChunk<InferOutput<TOutput>>> =>
|
|
190
|
+
// `self`, not `target`: the invocation has to be of the object that carries the name the
|
|
191
|
+
// audit record, the rate-limit key and the span are filed under.
|
|
192
|
+
llmStream<InferOutput<TOutput>>((sink) => withLlmSink(sink, () => self(input, opts ?? {}))),
|
|
193
|
+
named: (next: string): LlmAction<TInput, TOutput> => streamable(rename(next)),
|
|
194
|
+
});
|
|
195
|
+
return self;
|
|
121
196
|
}
|
|
122
197
|
|
|
123
198
|
async function generate<
|
|
@@ -134,13 +209,27 @@ async function generate<
|
|
|
134
209
|
// export name it exists before registration and can never twin.
|
|
135
210
|
const name = prompt.ref;
|
|
136
211
|
const model = def.model ?? prompt.model ?? DEFAULT_MODEL;
|
|
137
|
-
|
|
212
|
+
// `vars()` is the one declared place a model call loads data, so it is the one place the
|
|
213
|
+
// framework can refuse a `Secret` and the one place an app's redactor can see the row before it
|
|
214
|
+
// leaves the process. Both run here, between the load and the request, and neither is optional
|
|
215
|
+
// in the sense that matters: the redactor may be absent, the Secret check never is.
|
|
216
|
+
const vars = await def.vars({ input: args.input, ctx: args.ctx });
|
|
217
|
+
assertNoSecrets(name, vars);
|
|
218
|
+
const redact = aiRedactor();
|
|
219
|
+
const rawPrompt = prompt.render(vars);
|
|
220
|
+
const rendered = redact(rawPrompt);
|
|
221
|
+
const system = prompt.system === undefined ? undefined : redact(prompt.system);
|
|
222
|
+
const redacted = rendered !== rawPrompt || system !== prompt.system;
|
|
138
223
|
|
|
139
224
|
return withSpan('ai.llm', async (span) => {
|
|
140
225
|
span.setAttributes({
|
|
141
226
|
'llm.model': model,
|
|
142
227
|
'llm.prompt': prompt.ref,
|
|
143
228
|
'llm.prompt.hash': prompt.hash,
|
|
229
|
+
// Whether the installed redactor changed anything. Recorded because "we redact" is a claim
|
|
230
|
+
// an audit asks evidence for, and a redactor that silently stopped matching looks identical
|
|
231
|
+
// to one that had nothing to remove until this attribute separates them.
|
|
232
|
+
'llm.redacted': redacted,
|
|
144
233
|
});
|
|
145
234
|
|
|
146
235
|
// A cached answer is still data of unknown provenance, so it goes through the schema like
|
|
@@ -151,15 +240,18 @@ async function generate<
|
|
|
151
240
|
span.setAttribute('llm.cache.hit', hit !== undefined);
|
|
152
241
|
if (hit !== undefined) return hit.value;
|
|
153
242
|
|
|
154
|
-
|
|
243
|
+
// No `tools` yet: the `respond` projection belongs to the non-streaming path alone. A tool
|
|
244
|
+
// call is emitted whole, so forcing the answer through one leaves a stream with nothing to
|
|
245
|
+
// deliver until it is already over.
|
|
246
|
+
const base: GenerateRequest = {
|
|
155
247
|
model,
|
|
156
|
-
...(
|
|
248
|
+
...(system === undefined ? {} : { system }),
|
|
157
249
|
messages: [{ role: 'user', content: rendered }],
|
|
158
250
|
maxTokens: def.maxTokens ?? DEFAULT_MAX_TOKENS,
|
|
159
251
|
...(prompt.effort === undefined ? {} : { effort: prompt.effort }),
|
|
160
252
|
...(prompt.thinking === undefined ? {} : { thinking: prompt.thinking }),
|
|
161
|
-
tools: [respond],
|
|
162
253
|
};
|
|
254
|
+
const request: GenerateRequest = { ...base, tools: [respond] };
|
|
163
255
|
|
|
164
256
|
// A ledger derived from the ambient one, so a per-call budget can only TIGHTEN the actor
|
|
165
257
|
// and org ceilings this call runs inside, never widen them. The gateway reserves against
|
|
@@ -168,18 +260,19 @@ async function generate<
|
|
|
168
260
|
limitsOf(def.budget),
|
|
169
261
|
);
|
|
170
262
|
const gateway = aiGateway(name);
|
|
263
|
+
const sink = currentLlmSink();
|
|
171
264
|
|
|
172
265
|
return withBudget(ledger, async () => {
|
|
266
|
+
if (sink !== undefined) {
|
|
267
|
+
const value = await streamedAnswer(def.output, name, gateway, base, sink, span);
|
|
268
|
+
await cache?.remember(value);
|
|
269
|
+
return value;
|
|
270
|
+
}
|
|
173
271
|
let messages: readonly AiMessage[] = request.messages;
|
|
174
272
|
let issues = 'no output';
|
|
175
273
|
for (let attempt = 1; attempt <= ATTEMPTS; attempt += 1) {
|
|
176
274
|
const result = await gateway.generate({ ...request, messages });
|
|
177
|
-
span.setAttributes({
|
|
178
|
-
'llm.attempts': attempt,
|
|
179
|
-
'llm.stop': result.stopReason,
|
|
180
|
-
'llm.tokens': result.usage.inputTokens + result.usage.outputTokens,
|
|
181
|
-
'llm.cost.minor': result.cost.minor,
|
|
182
|
-
});
|
|
275
|
+
span.setAttributes({ 'llm.attempts': attempt, ...answerAttributes(result) });
|
|
183
276
|
// Branch on the stop reason BEFORE reading the answer. A refusal carries empty or partial
|
|
184
277
|
// content, so parsing it first reports a schema disagreement — a cause that is wrong, a
|
|
185
278
|
// fix that does not apply, and a repair turn spent buying the same refusal again.
|
|
@@ -187,9 +280,10 @@ async function generate<
|
|
|
187
280
|
throw new LlmRefusedError({
|
|
188
281
|
prompt: name,
|
|
189
282
|
model: result.model,
|
|
190
|
-
// The fix names a model the caller can paste
|
|
191
|
-
//
|
|
192
|
-
|
|
283
|
+
// The fix names a model the caller can paste, and only ever a MORE capable one:
|
|
284
|
+
// "the first id that differs" answered a refusal on the default model with the next
|
|
285
|
+
// entry down the ladder, which is a retry that cannot succeed.
|
|
286
|
+
alternative: moreCapableThan(result.model),
|
|
193
287
|
category: result.stopDetails?.category,
|
|
194
288
|
explanation: result.stopDetails?.explanation,
|
|
195
289
|
});
|
|
@@ -205,13 +299,77 @@ async function generate<
|
|
|
205
299
|
throw new LlmTruncatedError({ prompt: name, maxTokens: request.maxTokens });
|
|
206
300
|
}
|
|
207
301
|
issues = formatIssues(parsed.issues).join('; ');
|
|
208
|
-
|
|
302
|
+
const echo = assistantEcho(result);
|
|
303
|
+
messages =
|
|
304
|
+
echo === undefined
|
|
305
|
+
? [...messages, repair(issues)]
|
|
306
|
+
: [...messages, { role: 'assistant', content: echo }, repair(issues)];
|
|
209
307
|
}
|
|
210
308
|
throw new LlmOutputInvalidError({ prompt: name, attempts: ATTEMPTS, issues });
|
|
211
309
|
});
|
|
212
310
|
});
|
|
213
311
|
}
|
|
214
312
|
|
|
313
|
+
/**
|
|
314
|
+
* What one answered turn puts on the span. `llm.provider` is the half the LLM-gateway table
|
|
315
|
+
* called "a fallback is recorded in the span, never silent": fallback in this framework is across
|
|
316
|
+
* PROVIDERS serving one model, never across models, and until the gateway stamped the provider
|
|
317
|
+
* that answered, a fallback was exactly as silent as no fallback at all.
|
|
318
|
+
*/
|
|
319
|
+
export function answerAttributes(result: GenerateResult): SpanAttributes {
|
|
320
|
+
return {
|
|
321
|
+
'llm.stop': result.stopReason,
|
|
322
|
+
'llm.tokens': result.usage.inputTokens + result.usage.outputTokens,
|
|
323
|
+
'llm.cost.minor': result.cost.minor,
|
|
324
|
+
'llm.provider': result.provider ?? 'unknown',
|
|
325
|
+
};
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/**
|
|
329
|
+
* One streamed turn, from the same declaration and under the same rules — with one difference
|
|
330
|
+
* that is forced rather than chosen: no repair turn. The tokens are already on the consumer's
|
|
331
|
+
* screen, so a second answer would be two answers to one question; a stream gets one attempt and
|
|
332
|
+
* `X_LLM_STREAM_INVALID` names the non-streaming call as the fix.
|
|
333
|
+
*
|
|
334
|
+
* The stop reason is still read BEFORE the answer, for the reason it always was: a refusal
|
|
335
|
+
* carries empty or partial content and parsing it first reports a schema disagreement that is not
|
|
336
|
+
* one. Truncation is the same call it is on the non-streaming path.
|
|
337
|
+
*/
|
|
338
|
+
async function streamedAnswer<TOutput extends StandardSchemaV1>(
|
|
339
|
+
output: TOutput,
|
|
340
|
+
name: string,
|
|
341
|
+
gateway: Gateway,
|
|
342
|
+
request: GenerateRequest,
|
|
343
|
+
sink: LlmSink,
|
|
344
|
+
span: Span,
|
|
345
|
+
): Promise<InferOutput<TOutput>> {
|
|
346
|
+
const result = await streamOneTurn(gateway, request, sink);
|
|
347
|
+
span.setAttributes({ 'llm.attempts': 1, ...answerAttributes(result) });
|
|
348
|
+
if (result.stopReason === 'refusal') {
|
|
349
|
+
throw new LlmRefusedError({
|
|
350
|
+
prompt: name,
|
|
351
|
+
model: result.model,
|
|
352
|
+
alternative: moreCapableThan(result.model),
|
|
353
|
+
category: result.stopDetails?.category,
|
|
354
|
+
explanation: result.stopDetails?.explanation,
|
|
355
|
+
});
|
|
356
|
+
}
|
|
357
|
+
if (result.stopReason === 'max_tokens') {
|
|
358
|
+
throw new LlmTruncatedError({ prompt: name, maxTokens: request.maxTokens });
|
|
359
|
+
}
|
|
360
|
+
// Prose, and its JSON parse when it has one. Both, because a stream carries no `respond` tool
|
|
361
|
+
// to tell them apart: `output: t.string` is satisfied by the text itself, and an object schema
|
|
362
|
+
// by what the text parses to — trying only one of the two makes a legal declaration unusable.
|
|
363
|
+
const parsed = await accept(output, parseJsonish(result.text));
|
|
364
|
+
if (parsed !== undefined) return parsed.value;
|
|
365
|
+
const fallback = await validateAsync(output, result.text);
|
|
366
|
+
if (fallback.issues === undefined) return fallback.value;
|
|
367
|
+
throw new LlmStreamInvalidError({
|
|
368
|
+
prompt: name,
|
|
369
|
+
issues: formatIssues(fallback.issues).join('; '),
|
|
370
|
+
});
|
|
371
|
+
}
|
|
372
|
+
|
|
215
373
|
/** `undefined` for "does not fit". Wrapped so a legitimately falsy value is still a hit. */
|
|
216
374
|
async function accept<TOutput extends StandardSchemaV1>(
|
|
217
375
|
schema: TOutput,
|
|
@@ -242,7 +400,7 @@ function limitsOf(budget: LlmBudget | undefined): BudgetLimits {
|
|
|
242
400
|
* a model and an agent are shown one shape, and a schema it cannot express throws HERE, at
|
|
243
401
|
* declaration time, rather than degrading into a permissive node the model cannot satisfy.
|
|
244
402
|
*/
|
|
245
|
-
function respondToolFor(output: StandardSchemaV1): LlmTool {
|
|
403
|
+
export function respondToolFor(output: StandardSchemaV1): LlmTool {
|
|
246
404
|
return {
|
|
247
405
|
name: RESPOND,
|
|
248
406
|
description: 'Return the result. Call this exactly once; do not answer in prose.',
|
|
@@ -251,11 +409,30 @@ function respondToolFor(output: StandardSchemaV1): LlmTool {
|
|
|
251
409
|
};
|
|
252
410
|
}
|
|
253
411
|
|
|
412
|
+
/**
|
|
413
|
+
* What the model answered, as text the Messages API will accept — or nothing.
|
|
414
|
+
*
|
|
415
|
+
* `result.text` is the EMPTY STRING whenever the answer came through the `respond` tool, which
|
|
416
|
+
* is the dominant path: an empty text block is a 400 (`text content blocks must be non-empty`),
|
|
417
|
+
* so the repair turn came back as `X_AI_PROVIDER_UNAVAILABLE` and the caller never saw the
|
|
418
|
+
* `X_LLM_OUTPUT_INVALID` this loop exists to raise. The tool call's own arguments ARE the answer
|
|
419
|
+
* in that case, and replaying them is what gives the repair turn its context — `AiMessage`
|
|
420
|
+
* carries a string, so the `tool_use` block cannot survive the round trip as itself, and
|
|
421
|
+
* replaying it as text avoids the `tool_result` the API would then demand of the next message.
|
|
422
|
+
*/
|
|
423
|
+
function assistantEcho(result: GenerateResult): string | undefined {
|
|
424
|
+
if (result.text !== '') return result.text;
|
|
425
|
+
const call = result.toolCalls.find((c) => c.name === RESPOND) ?? result.toolCalls[0];
|
|
426
|
+
if (call === undefined) return undefined;
|
|
427
|
+
const replayed = JSON.stringify(call.input);
|
|
428
|
+
return replayed === undefined || replayed === '' ? undefined : replayed;
|
|
429
|
+
}
|
|
430
|
+
|
|
254
431
|
/**
|
|
255
432
|
* The tool call if the model made one, otherwise the text parsed as JSON — a model that
|
|
256
433
|
* answers in prose is a schema failure, not a crash, so it flows into the repair turn.
|
|
257
434
|
*/
|
|
258
|
-
function structuredOutputOf(result: GenerateResult): unknown {
|
|
435
|
+
export function structuredOutputOf(result: GenerateResult): unknown {
|
|
259
436
|
const call = result.toolCalls.find((c) => c.name === RESPOND);
|
|
260
437
|
if (call !== undefined) return call.input;
|
|
261
438
|
return parseJsonish(result.text);
|