@ultimat3/ai 1.2.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.
- package/CLAUDE.md +363 -0
- package/README.md +229 -6
- package/package.json +10 -9
- package/src/agent.ts +287 -0
- package/src/budget.ts +98 -11
- package/src/error-body.ts +44 -0
- package/src/errors.ts +142 -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 +40 -10
- package/src/index.ts +45 -8
- 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 +260 -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 +94 -35
- 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 +13 -4
- package/src/vector.ts +0 -0
- package/src/wire.ts +41 -11
|
@@ -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
|
+
});
|
package/src/fix-line.ts
ADDED
|
@@ -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
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
await ledger?.
|
|
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
|
-
|
|
123
|
-
|
|
124
|
-
|
|
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
|
|
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 {
|
|
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 {
|
|
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
|
+
}
|