@ultimat3/ai 30.9.0 → 31.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 +20 -11
- package/README.md +47 -22
- package/package.json +13 -12
- package/src/adapter.ts +21 -0
- package/src/agent-compact.ts +5 -2
- package/src/agent-continue.ts +4 -69
- package/src/agent-def.ts +6 -1
- package/src/agent-session.ts +66 -0
- package/src/agent-system.ts +1 -1
- package/src/agent-tools.ts +123 -0
- package/src/agent-turn.ts +14 -4
- package/src/agent.ts +43 -88
- package/src/budget-store.ts +82 -0
- package/src/budget.ts +7 -78
- package/src/embeddings.ts +1 -1
- package/src/errors.ts +5 -5
- package/src/eval-agent.ts +1 -1
- package/src/gateway-providers.ts +22 -10
- package/src/gateway.ts +20 -21
- package/src/index.ts +6 -71
- package/src/llm-answer.ts +125 -0
- package/src/llm-cache.ts +16 -2
- package/src/llm-def.ts +1 -1
- package/src/llm-instrument.ts +100 -0
- package/src/llm.ts +152 -241
- package/src/openai-provider.ts +2 -1
- package/src/openai-wire-stream.ts +243 -0
- package/src/openai-wire.ts +9 -236
- package/src/pg-vector-sql.ts +30 -2
- package/src/provider.ts +1 -1
- package/src/runtime.ts +83 -38
- package/src/sampling.ts +4 -4
- package/src/scorers.ts +1 -1
- package/src/tools.ts +12 -8
- package/src/vector.ts +3 -1
package/src/gateway-providers.ts
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
// core `breaker()` per provider: a provider down for an hour cost `attempts` calls plus backoff on
|
|
4
4
|
// EVERY call; behind its breaker it costs `attempts × failures` per cooldown, then none.
|
|
5
5
|
|
|
6
|
-
import type { Breaker, BreakerTicket, Random } from '@ultimat3/core';
|
|
6
|
+
import type { Breaker, BreakerTicket, Random, RetryPolicy } from '@ultimat3/core';
|
|
7
7
|
import {
|
|
8
8
|
backoffDelay,
|
|
9
9
|
breaker,
|
|
@@ -12,10 +12,10 @@ import {
|
|
|
12
12
|
renderThrowable,
|
|
13
13
|
settleWith,
|
|
14
14
|
stringField,
|
|
15
|
+
systemClock,
|
|
15
16
|
tryUseContext,
|
|
16
17
|
} from '@ultimat3/core';
|
|
17
18
|
import { AiProviderUnavailableError } from './errors';
|
|
18
|
-
import type { RetryPolicy } from './gateway';
|
|
19
19
|
import type { ModelId } from './models';
|
|
20
20
|
import type { GenerateResult, Provider } from './provider';
|
|
21
21
|
|
|
@@ -33,17 +33,18 @@ export const DEFAULT_GATEWAY_BREAKER = Object.freeze({ failures: 5, cooldownMs:
|
|
|
33
33
|
const PROVIDER_UNAVAILABLE = 'X_AI_PROVIDER_UNAVAILABLE';
|
|
34
34
|
|
|
35
35
|
/**
|
|
36
|
-
*
|
|
37
|
-
*
|
|
38
|
-
*
|
|
36
|
+
* The wait before `attempt + 1`: core's `backoffDelay` over core's `RetryPolicy`, every curve field
|
|
37
|
+
* the policy's own (`DEFAULT_GATEWAY_RETRY` is full jitter). `gateway-backoff.test.ts` pins the
|
|
38
|
+
* refusal of a non-finite bound and the clamped negative base.
|
|
39
39
|
*/
|
|
40
40
|
export function backoffMs(policy: RetryPolicy, attempt: number, random?: Random): number {
|
|
41
41
|
return backoffDelay({
|
|
42
42
|
attempt,
|
|
43
|
-
base: policy.
|
|
44
|
-
max: policy.
|
|
45
|
-
|
|
46
|
-
|
|
43
|
+
base: policy.base,
|
|
44
|
+
max: policy.max,
|
|
45
|
+
factor: policy.factor,
|
|
46
|
+
curve: policy.curve,
|
|
47
|
+
jitter: policy.jitter,
|
|
47
48
|
random,
|
|
48
49
|
});
|
|
49
50
|
}
|
|
@@ -122,6 +123,12 @@ export interface AttemptInput {
|
|
|
122
123
|
export async function attemptProviders(input: AttemptInput): Promise<GenerateResult> {
|
|
123
124
|
const { model, retry } = input;
|
|
124
125
|
const failures: string[] = [];
|
|
126
|
+
// Read once, and only compared when a budget is set: core's `retryLoop` rule — a wait that would
|
|
127
|
+
// end past `timeBudgetMs` is never started, across every provider of this call.
|
|
128
|
+
const startedAt = systemClock.monotonic();
|
|
129
|
+
const overruns = (delayMs: number): boolean =>
|
|
130
|
+
retry.timeBudgetMs !== undefined &&
|
|
131
|
+
systemClock.monotonic() - startedAt + delayMs > retry.timeBudgetMs;
|
|
125
132
|
if (input.candidates.length === 0) {
|
|
126
133
|
throw new AiProviderUnavailableError({
|
|
127
134
|
model,
|
|
@@ -164,7 +171,12 @@ export async function attemptProviders(input: AttemptInput): Promise<GenerateRes
|
|
|
164
171
|
exhausted(gate, ticket);
|
|
165
172
|
break;
|
|
166
173
|
}
|
|
167
|
-
|
|
174
|
+
const delayMs = backoffMs(retry, attempt, input.random);
|
|
175
|
+
if (overruns(delayMs)) {
|
|
176
|
+
exhausted(gate, ticket);
|
|
177
|
+
throw new AiProviderUnavailableError({ model, attempts: failures });
|
|
178
|
+
}
|
|
179
|
+
await input.sleep(delayMs);
|
|
168
180
|
}
|
|
169
181
|
}
|
|
170
182
|
}
|
package/src/gateway.ts
CHANGED
|
@@ -4,8 +4,8 @@
|
|
|
4
4
|
// budgeted, and cost-accounted in integer minor units. Everything an app does with a model
|
|
5
5
|
// goes through here, so budgets and accounting cannot be bypassed by a stray fetch.
|
|
6
6
|
|
|
7
|
-
import type { Breaker, Random } from '@ultimat3/core';
|
|
8
|
-
import { fingerprint, finiteCount, logger, renderThrowable } from '@ultimat3/core';
|
|
7
|
+
import type { Breaker, Random, RetryPolicy } from '@ultimat3/core';
|
|
8
|
+
import { fingerprint, finiteCount, finiteOption, logger, renderThrowable } from '@ultimat3/core';
|
|
9
9
|
import type { Money } from '@ultimat3/money';
|
|
10
10
|
import { billedOf } from './billed';
|
|
11
11
|
import type { BudgetKeys, BudgetLimits, BudgetReservation, BudgetStore } from './budget';
|
|
@@ -39,24 +39,15 @@ export interface GatewayCache {
|
|
|
39
39
|
}
|
|
40
40
|
|
|
41
41
|
/**
|
|
42
|
-
*
|
|
43
|
-
*
|
|
44
|
-
*
|
|
45
|
-
* arithmetic, and that is gone — `backoffMs` is core's `backoffDelay` with this shape mapped onto
|
|
46
|
-
* it, so there is one curve in the framework and one place a jitter bug can live.
|
|
42
|
+
* The gateway's retry when `providerGateway({ retry })` names none. Core's `RetryPolicy`, the one
|
|
43
|
+
* retry shape in the framework since 31.0.0: full jitter, because N workers backing off a rate limit
|
|
44
|
+
* in step reproduce it.
|
|
47
45
|
*/
|
|
48
|
-
export interface RetryPolicy {
|
|
49
|
-
/** Total attempts including the first. */
|
|
50
|
-
readonly attempts: number;
|
|
51
|
-
/** First backoff in ms; doubled per attempt with full jitter. */
|
|
52
|
-
readonly baseDelayMs: number;
|
|
53
|
-
readonly maxDelayMs: number;
|
|
54
|
-
}
|
|
55
|
-
|
|
56
46
|
export const DEFAULT_GATEWAY_RETRY: RetryPolicy = {
|
|
57
47
|
attempts: 3,
|
|
58
|
-
|
|
59
|
-
|
|
48
|
+
base: 500,
|
|
49
|
+
max: 8_000,
|
|
50
|
+
jitter: 'full',
|
|
60
51
|
};
|
|
61
52
|
|
|
62
53
|
export type { GatewayBreakerOption } from './gateway-providers';
|
|
@@ -68,6 +59,10 @@ export interface ProviderGatewayInput {
|
|
|
68
59
|
readonly budget?: BudgetLimits;
|
|
69
60
|
readonly budgetStore?: BudgetStore;
|
|
70
61
|
readonly cache?: GatewayCache;
|
|
62
|
+
/**
|
|
63
|
+
* Core's `RetryPolicy`, read per provider: `attempts` each, `base`/`max`/`curve`/`factor`/`jitter`
|
|
64
|
+
* for the wait, and `timeBudgetMs` across the whole call, every provider included.
|
|
65
|
+
*/
|
|
71
66
|
readonly retry?: RetryPolicy;
|
|
72
67
|
readonly defaultModel?: ModelId;
|
|
73
68
|
/** Overridable so a test can run backoff without waiting. */
|
|
@@ -88,7 +83,7 @@ export interface ProviderGatewayInput {
|
|
|
88
83
|
}
|
|
89
84
|
|
|
90
85
|
export interface Gateway {
|
|
91
|
-
/** The wire formats of the providers serving `model` — what `llm.sampling.ignored` is read from. */
|
|
86
|
+
/** The wire formats of the providers serving `model` — what `ultimate.llm.sampling.ignored` is read from. */
|
|
92
87
|
samplingWires?(model: ModelId): readonly SamplingWire[];
|
|
93
88
|
/**
|
|
94
89
|
* Whether a provider serving `model` would send a hinted request with a cache mark — so its
|
|
@@ -142,10 +137,14 @@ class GatewayImpl implements Gateway {
|
|
|
142
137
|
// `X_AI_PROVIDER_UNAVAILABLE` with an EMPTY attempt list — measured, "no provider could serve
|
|
143
138
|
// model <id> ()" for a provider that was never asked. A floor of 1 because the field
|
|
144
139
|
// is documented as total attempts INCLUDING the first, so zero of them is not a policy.
|
|
145
|
-
// `
|
|
146
|
-
//
|
|
147
|
-
//
|
|
140
|
+
// `base` and `max` are deliberately not screened here: `backoffDelay` refuses a non-finite one
|
|
141
|
+
// already, and a NEGATIVE base is clamped to a zero wait on purpose (`gateway-backoff.test.ts`
|
|
142
|
+
// pins it), which a count check here would start refusing. The budget IS screened: a NaN one
|
|
143
|
+
// compares false against every elapsed time, so it would never stop anything.
|
|
148
144
|
finiteCount('gateway', 'retry.attempts', this.retry.attempts, 1);
|
|
145
|
+
if (this.retry.timeBudgetMs !== undefined) {
|
|
146
|
+
finiteOption('gateway', 'retry.timeBudgetMs', this.retry.timeBudgetMs);
|
|
147
|
+
}
|
|
149
148
|
// Screened here, under the config's own key, rather than by the ledger `callLedger` builds:
|
|
150
149
|
// that refused on the first CALL and named `the AI budget`, not the `gateway` to edit.
|
|
151
150
|
if (config.budget !== undefined) assertFiniteLimits(config.budget, 'gateway', 'budget.');
|
package/src/index.ts
CHANGED
|
@@ -46,7 +46,7 @@ export {
|
|
|
46
46
|
LlmTruncatedError,
|
|
47
47
|
} from './answer-errors';
|
|
48
48
|
export type { Billed } from './billed';
|
|
49
|
-
export {
|
|
49
|
+
export { billedOf } from './billed';
|
|
50
50
|
export type {
|
|
51
51
|
BudgetKeys,
|
|
52
52
|
BudgetLedger,
|
|
@@ -65,21 +65,6 @@ export {
|
|
|
65
65
|
memoryBudgetStore,
|
|
66
66
|
withBudget,
|
|
67
67
|
} from './budget';
|
|
68
|
-
export type {
|
|
69
|
-
AiDocumentBlock,
|
|
70
|
-
AiDocumentSource,
|
|
71
|
-
AiImageBlock,
|
|
72
|
-
AiImageSource,
|
|
73
|
-
AiMediaBlock,
|
|
74
|
-
ImageMediaType,
|
|
75
|
-
} from './content-blocks';
|
|
76
|
-
export {
|
|
77
|
-
IMAGE_MEDIA_TYPES,
|
|
78
|
-
IMAGE_TOKEN_ESTIMATE,
|
|
79
|
-
isMediaBlock,
|
|
80
|
-
MAX_DOCUMENT_BASE64_CHARS,
|
|
81
|
-
MAX_IMAGE_BASE64_CHARS,
|
|
82
|
-
} from './content-blocks';
|
|
83
68
|
export type { ContentRefusal } from './content-errors';
|
|
84
69
|
export { AiContentUnsupportedError } from './content-errors';
|
|
85
70
|
export { AiContextOverflowError } from './context-overflow';
|
|
@@ -101,12 +86,6 @@ export {
|
|
|
101
86
|
AgentMaxTurnsError,
|
|
102
87
|
AgentToolUnexposedError,
|
|
103
88
|
AiBudgetExceededError,
|
|
104
|
-
AiGatewayMissingError,
|
|
105
|
-
AiKeyMissingError,
|
|
106
|
-
AiModelUnknownError,
|
|
107
|
-
AiPromptRenderError,
|
|
108
|
-
AiPromptSecretError,
|
|
109
|
-
AiPromptVersionError,
|
|
110
89
|
AiProviderUnavailableError,
|
|
111
90
|
AiRequestInvalidError,
|
|
112
91
|
AiTransportError,
|
|
@@ -119,17 +98,8 @@ export {
|
|
|
119
98
|
} from './errors';
|
|
120
99
|
export { AI_ERROR_RETRY } from './errors-retry';
|
|
121
100
|
export type { EvalBaseline, Regression } from './eval-baseline';
|
|
101
|
+
export { baselinePath, RECORD_ENV, readBaseline, recordingBaselines } from './eval-baseline';
|
|
122
102
|
export {
|
|
123
|
-
baselinePath,
|
|
124
|
-
describeRegression,
|
|
125
|
-
RECORD_ENV,
|
|
126
|
-
readBaseline,
|
|
127
|
-
recordingBaselines,
|
|
128
|
-
regressionsAgainst,
|
|
129
|
-
writeBaseline,
|
|
130
|
-
} from './eval-baseline';
|
|
131
|
-
export {
|
|
132
|
-
EvalBaselineInvalidError,
|
|
133
103
|
EvalBaselineMissingError,
|
|
134
104
|
EvalMissingError,
|
|
135
105
|
EvalRecordingError,
|
|
@@ -148,24 +118,16 @@ export type {
|
|
|
148
118
|
EvaluatedAgent,
|
|
149
119
|
PromptEvalInput,
|
|
150
120
|
} from './evals';
|
|
151
|
-
export {
|
|
152
|
-
baselineFrom,
|
|
153
|
-
defineEval,
|
|
154
|
-
describeEvals,
|
|
155
|
-
promptsWithoutEvals,
|
|
156
|
-
resetEvals,
|
|
157
|
-
} from './evals';
|
|
121
|
+
export { defineEval, describeEvals, promptsWithoutEvals, resetEvals } from './evals';
|
|
158
122
|
export type { AiFetch } from './fetch-seam';
|
|
159
123
|
export type {
|
|
160
124
|
Gateway,
|
|
161
125
|
GatewayBreakerOption,
|
|
162
126
|
GatewayCache,
|
|
163
127
|
ProviderGatewayInput,
|
|
164
|
-
RetryPolicy,
|
|
165
128
|
} from './gateway';
|
|
166
129
|
export {
|
|
167
130
|
backoffMs,
|
|
168
|
-
DEFAULT_GATEWAY_BREAKER,
|
|
169
131
|
DEFAULT_GATEWAY_RETRY,
|
|
170
132
|
isRetryable,
|
|
171
133
|
promptCacheKey,
|
|
@@ -173,7 +135,6 @@ export {
|
|
|
173
135
|
} from './gateway';
|
|
174
136
|
export type { HiveDef, HiveSplitArgs } from './hive';
|
|
175
137
|
export { hive } from './hive';
|
|
176
|
-
export { HiveEmptyError } from './hive-errors';
|
|
177
138
|
export type { HiveMember, HiveMemberError, HiveOutput, HiveResult } from './hive-result';
|
|
178
139
|
export type { InProcessToolDef } from './in-process-tool';
|
|
179
140
|
export { inProcessTool } from './in-process-tool';
|
|
@@ -182,7 +143,6 @@ export { LlmTextChunk, llm } from './llm';
|
|
|
182
143
|
export type { LlmCache, LlmScopeArgs, LlmSemanticCache } from './llm-cache';
|
|
183
144
|
export type { LlmStreamChunk } from './llm-stream';
|
|
184
145
|
export type { ModelSource } from './model-resolve';
|
|
185
|
-
export { AiModelUnresolvedError } from './model-resolve';
|
|
186
146
|
export type {
|
|
187
147
|
ContentKind,
|
|
188
148
|
DeclaredReasoning,
|
|
@@ -193,10 +153,7 @@ export type {
|
|
|
193
153
|
ThinkingMode,
|
|
194
154
|
} from './models';
|
|
195
155
|
export {
|
|
196
|
-
assertModel,
|
|
197
|
-
EFFORTS,
|
|
198
156
|
isModelRegistered,
|
|
199
|
-
modelIds,
|
|
200
157
|
modelSpec,
|
|
201
158
|
moreCapableThan,
|
|
202
159
|
registeredModels,
|
|
@@ -207,12 +164,7 @@ export type { OpenAiProviderInput } from './openai-provider';
|
|
|
207
164
|
export { openAiProvider } from './openai-provider';
|
|
208
165
|
export type { PostgresVectorStore, PostgresVectorStoreInput } from './pg-vector';
|
|
209
166
|
export { postgresVectorStore } from './pg-vector';
|
|
210
|
-
export type {
|
|
211
|
-
PgHybridArgs,
|
|
212
|
-
PgSearchArgs,
|
|
213
|
-
PgVectorRowInput,
|
|
214
|
-
PgVectorTable,
|
|
215
|
-
} from './pg-vector-sql';
|
|
167
|
+
export type { PgSearchArgs, PgVectorTable } from './pg-vector-sql';
|
|
216
168
|
export {
|
|
217
169
|
conditionsSql,
|
|
218
170
|
ddlSql,
|
|
@@ -220,14 +172,7 @@ export {
|
|
|
220
172
|
searchSql,
|
|
221
173
|
} from './pg-vector-sql';
|
|
222
174
|
export type { DefinePromptInput, Prompt, PromptVars } from './prompt';
|
|
223
|
-
export {
|
|
224
|
-
definePrompt,
|
|
225
|
-
describePrompts,
|
|
226
|
-
getPrompt,
|
|
227
|
-
promptHash,
|
|
228
|
-
promptVersions,
|
|
229
|
-
resetPrompts,
|
|
230
|
-
} from './prompt';
|
|
175
|
+
export { definePrompt, describePrompts, promptHash, resetPrompts } from './prompt';
|
|
231
176
|
export type {
|
|
232
177
|
CacheWire,
|
|
233
178
|
PromptCacheHint,
|
|
@@ -264,14 +209,11 @@ export {
|
|
|
264
209
|
costOf,
|
|
265
210
|
estimateCost,
|
|
266
211
|
estimateInputTokens,
|
|
267
|
-
estimateTextTokens,
|
|
268
212
|
estimateTokens,
|
|
269
213
|
isTruncated,
|
|
270
214
|
messageText,
|
|
271
215
|
parseMessage,
|
|
272
|
-
requiresStreaming,
|
|
273
216
|
STREAM_ONLY_MAX_TOKENS,
|
|
274
|
-
totalTokens,
|
|
275
217
|
} from './provider';
|
|
276
218
|
export type {
|
|
277
219
|
AssembledContext,
|
|
@@ -280,13 +222,7 @@ export type {
|
|
|
280
222
|
Reranker,
|
|
281
223
|
RetrieveInput,
|
|
282
224
|
} from './rag';
|
|
283
|
-
export {
|
|
284
|
-
assembleContext,
|
|
285
|
-
chunkDocument,
|
|
286
|
-
indexDocument,
|
|
287
|
-
passthroughReranker,
|
|
288
|
-
retrieve,
|
|
289
|
-
} from './rag';
|
|
225
|
+
export { assembleContext, chunkDocument, indexDocument, retrieve } from './rag';
|
|
290
226
|
export { assertNoSecrets } from './redaction';
|
|
291
227
|
export type { RemoteEmbedder, RemoteEmbedderInput } from './remote-embedder';
|
|
292
228
|
export { remoteEmbedder } from './remote-embedder';
|
|
@@ -339,4 +275,3 @@ export type {
|
|
|
339
275
|
export { fuse, memoryVectorStore } from './vector';
|
|
340
276
|
export type { VectorScope } from './vector-scope';
|
|
341
277
|
export { NO_VECTOR_TENANT, tenantOf, UNSCOPED, VectorUnscopedError } from './vector-scope';
|
|
342
|
-
export type { StreamState } from './wire';
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
// Single responsibility: what one answered `llm()` turn amounts to — its span attributes, its
|
|
2
|
+
// `onCall` report, whether a value fits the declared `output`, and the one streamed turn's answer
|
|
3
|
+
// under the same rules. Split from ./llm at its line ceiling.
|
|
4
|
+
|
|
5
|
+
import type { Span, SpanAttributes } from '@ultimat3/core';
|
|
6
|
+
import { SEMCONV } from '@ultimat3/core';
|
|
7
|
+
import type { InferOutput, StandardSchemaV1 } from '@ultimat3/schema';
|
|
8
|
+
import { formatIssues, validateAsync } from '@ultimat3/schema';
|
|
9
|
+
import { runTotal } from './agent-usage';
|
|
10
|
+
import { LlmTruncatedError } from './answer-errors';
|
|
11
|
+
import { LlmRefusedError, LlmStreamInvalidError } from './errors';
|
|
12
|
+
import type { Gateway } from './gateway';
|
|
13
|
+
import type { LlmDef } from './llm-def';
|
|
14
|
+
import type { LlmSink } from './llm-stream';
|
|
15
|
+
import { streamOneTurn } from './llm-stream';
|
|
16
|
+
import { moreCapableThan } from './models';
|
|
17
|
+
import type { PromptVars } from './prompt';
|
|
18
|
+
import type { GenerateRequest, GenerateResult } from './provider';
|
|
19
|
+
import { isTruncated } from './provider';
|
|
20
|
+
import { parseJsonish } from './respond';
|
|
21
|
+
import type { RunMeter } from './run-meter';
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* What one answered turn puts on the span. The provider that answered is the half the LLM-gateway
|
|
25
|
+
* table called "a fallback is recorded in the span, never silent": fallback in this framework is
|
|
26
|
+
* across PROVIDERS serving one model, never across models, and until the gateway stamped the
|
|
27
|
+
* provider that answered, a fallback was exactly as silent as no fallback at all. It lands as
|
|
28
|
+
* semconv's `gen_ai.provider.name`, with the token usage, when the call settles (`llm-instrument.ts`).
|
|
29
|
+
*/
|
|
30
|
+
export function answerAttributes(result: GenerateResult): SpanAttributes {
|
|
31
|
+
return {
|
|
32
|
+
'ultimate.llm.stop': result.stopReason,
|
|
33
|
+
'ultimate.llm.cost.minor': result.cost.minor,
|
|
34
|
+
[SEMCONV.genAiProviderName]: result.provider ?? 'unknown',
|
|
35
|
+
[SEMCONV.genAiResponseModel]: result.model,
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* One streamed turn, from the same declaration and under the same rules — with one difference
|
|
41
|
+
* that is forced rather than chosen: no repair turn. The tokens are already on the consumer's
|
|
42
|
+
* screen, so a second answer would be two answers to one question; a stream gets one attempt and
|
|
43
|
+
* `X_LLM_STREAM_INVALID` names the non-streaming call as the fix.
|
|
44
|
+
*
|
|
45
|
+
* The stop reason is still read BEFORE the answer, for the reason it always was: a refusal
|
|
46
|
+
* carries empty or partial content and parsing it first reports a schema disagreement that is not
|
|
47
|
+
* one. Truncation is the same call it is on the non-streaming path.
|
|
48
|
+
*/
|
|
49
|
+
export async function streamedAnswer<
|
|
50
|
+
TInput extends StandardSchemaV1,
|
|
51
|
+
TOutput extends StandardSchemaV1,
|
|
52
|
+
V extends PromptVars,
|
|
53
|
+
>(
|
|
54
|
+
def: LlmDef<TInput, TOutput, V>,
|
|
55
|
+
name: string,
|
|
56
|
+
gateway: Gateway,
|
|
57
|
+
request: GenerateRequest,
|
|
58
|
+
sink: LlmSink,
|
|
59
|
+
span: Span,
|
|
60
|
+
meter: RunMeter,
|
|
61
|
+
): Promise<InferOutput<TOutput>> {
|
|
62
|
+
const { output } = def;
|
|
63
|
+
const result = await meter.call(streamOneTurn(gateway, request, sink));
|
|
64
|
+
span.setAttributes({ 'ultimate.llm.attempts': 1, ...answerAttributes(result) });
|
|
65
|
+
await observeCall(def, meter, result, 1, undefined);
|
|
66
|
+
if (result.stopReason === 'refusal') {
|
|
67
|
+
throw new LlmRefusedError({
|
|
68
|
+
prompt: name,
|
|
69
|
+
model: result.model,
|
|
70
|
+
alternative: moreCapableThan(result.model, request),
|
|
71
|
+
category: result.stopDetails?.category,
|
|
72
|
+
explanation: result.stopDetails?.explanation,
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
if (isTruncated(result.stopReason)) {
|
|
76
|
+
throw new LlmTruncatedError({ prompt: name, maxTokens: request.maxTokens });
|
|
77
|
+
}
|
|
78
|
+
// Prose, and its JSON parse when it has one. Both, because a stream carries no `respond` tool
|
|
79
|
+
// to tell them apart: `output: t.string` is satisfied by the text itself, and an object schema
|
|
80
|
+
// by what the text parses to — trying only one of the two makes a legal declaration unusable.
|
|
81
|
+
const parsed = await accept(output, parseJsonish(result.text));
|
|
82
|
+
if (parsed !== undefined) return parsed.value;
|
|
83
|
+
const fallback = await validateAsync(output, result.text);
|
|
84
|
+
if (fallback.issues === undefined) return fallback.value;
|
|
85
|
+
throw new LlmStreamInvalidError({
|
|
86
|
+
prompt: name,
|
|
87
|
+
issues: formatIssues(fallback.issues).join('; '),
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** One provider call, reported to the declaration's `onCall`. Awaited; a throw fails the call. */
|
|
92
|
+
export async function observeCall<
|
|
93
|
+
TInput extends StandardSchemaV1,
|
|
94
|
+
TOutput extends StandardSchemaV1,
|
|
95
|
+
V extends PromptVars,
|
|
96
|
+
>(
|
|
97
|
+
def: LlmDef<TInput, TOutput, V>,
|
|
98
|
+
meter: RunMeter,
|
|
99
|
+
result: GenerateResult,
|
|
100
|
+
attempt: number,
|
|
101
|
+
repairOf: readonly string[] | undefined,
|
|
102
|
+
): Promise<void> {
|
|
103
|
+
if (def.onCall === undefined) return;
|
|
104
|
+
await def.onCall({
|
|
105
|
+
attempt,
|
|
106
|
+
model: result.model,
|
|
107
|
+
provider: result.provider,
|
|
108
|
+
stopReason: result.stopReason,
|
|
109
|
+
usage: result.usage,
|
|
110
|
+
cost: result.cost,
|
|
111
|
+
...(result.costExact === undefined ? {} : { costExact: result.costExact }),
|
|
112
|
+
...(repairOf === undefined ? {} : { repairOf }),
|
|
113
|
+
run: runTotal(result.model, undefined, meter.total()),
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** `undefined` for "does not fit". Wrapped so a legitimately falsy value is still a hit. */
|
|
118
|
+
export async function accept<TOutput extends StandardSchemaV1>(
|
|
119
|
+
schema: TOutput,
|
|
120
|
+
value: unknown,
|
|
121
|
+
): Promise<{ readonly value: InferOutput<TOutput> } | undefined> {
|
|
122
|
+
if (value === undefined) return undefined;
|
|
123
|
+
const parsed = await validateAsync(schema, value);
|
|
124
|
+
return parsed.issues === undefined ? { value: parsed.value } : undefined;
|
|
125
|
+
}
|
package/src/llm-cache.ts
CHANGED
|
@@ -66,7 +66,9 @@ export interface PromptCache {
|
|
|
66
66
|
|
|
67
67
|
/**
|
|
68
68
|
* The partition a declaration that named none gets: the narrowest key this call can prove, which
|
|
69
|
-
* is the actor itself
|
|
69
|
+
* is the actor itself AND its grant set — one id is not one grant set (a role revoked inside the
|
|
70
|
+
* `ttl`, one identity holding two role sets), and `vars()` may read `ctx.actor.roles`, so an
|
|
71
|
+
* id-only key handed a member an admin's answer. Verbatim the shape `@ultimat3/query`'s `actorAuthority` builds, for the
|
|
70
72
|
* same reason it is JSON rather than a joined string — an actor id is app data and may carry any
|
|
71
73
|
* separator, and a value that can spell a boundary can spell somebody else's.
|
|
72
74
|
*
|
|
@@ -79,9 +81,21 @@ export interface PromptCache {
|
|
|
79
81
|
* everywhere else in the framework.
|
|
80
82
|
*/
|
|
81
83
|
function actorScope(ctx: Ctx): string {
|
|
82
|
-
|
|
84
|
+
const { actor } = ctx;
|
|
85
|
+
return JSON.stringify([
|
|
86
|
+
actor.kind,
|
|
87
|
+
actor.id,
|
|
88
|
+
actor.orgId ?? null,
|
|
89
|
+
grantSet(actor.roles),
|
|
90
|
+
grantSet(actor.scopes),
|
|
91
|
+
grantSet(actor.permissions),
|
|
92
|
+
actor.onBehalfOf === undefined ? null : [actor.onBehalfOf.actorKind, actor.onBehalfOf.actorId],
|
|
93
|
+
]);
|
|
83
94
|
}
|
|
84
95
|
|
|
96
|
+
/** Sorted, de-duplicated: a set, so two orderings of one grant are one partition. */
|
|
97
|
+
const grantSet = (values: readonly string[]): readonly string[] => [...new Set(values)].sort();
|
|
98
|
+
|
|
85
99
|
/**
|
|
86
100
|
* The semantic cache for one declaration, or `undefined` when none was declared. The instance
|
|
87
101
|
* is partitioned by prompt VERSION as well as scope, which is what makes "editing a prompt
|
package/src/llm-def.ts
CHANGED
|
@@ -61,7 +61,7 @@ export interface LlmDef<
|
|
|
61
61
|
* `temperature` (0 to 2) and `seed` (a whole number), for a call that should answer the same way
|
|
62
62
|
* twice — a verifier, a classifier. Sent only to a model whose row takes them
|
|
63
63
|
* (`registerModel({ sampling })`); to any other they are NOT sent, because a reasoning model
|
|
64
|
-
* answers one with a 400 — the `llm.sampling.ignored` span attribute names what was left out.
|
|
64
|
+
* answers one with a 400 — the `ultimate.llm.sampling.ignored` span attribute names what was left out.
|
|
65
65
|
* `seed` exists on the chat-completions wire only, and is best effort where it exists.
|
|
66
66
|
*/
|
|
67
67
|
readonly temperature?: number;
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
// Single responsibility: what one `llm()` call reports when it settles — the `ai.llm` event on
|
|
2
|
+
// core's instrumentation bus and the OpenTelemetry gen_ai usage on its span — on BOTH paths, a
|
|
3
|
+
// cache hit included. Split from ./llm at its line ceiling; the meter is the one place every
|
|
4
|
+
// provider call passes, so it is where the answering provider and model are read.
|
|
5
|
+
|
|
6
|
+
import type { Span } from '@ultimat3/core';
|
|
7
|
+
import { instrument, SEMCONV, subscribed } from '@ultimat3/core';
|
|
8
|
+
import type { GenerateResult } from './provider';
|
|
9
|
+
import type { RunMeter } from './run-meter';
|
|
10
|
+
|
|
11
|
+
/** What a cache hit, or a call no provider ever answered, reports as its provider. */
|
|
12
|
+
export const NO_PROVIDER = 'unknown';
|
|
13
|
+
|
|
14
|
+
export interface LlmReport {
|
|
15
|
+
/** The run's meter, watched: hand THIS to every provider call so the answerer is seen. */
|
|
16
|
+
readonly meter: RunMeter;
|
|
17
|
+
/** Settle the call: usage onto the span, `ai.llm` onto the bus. Never throws of its own. */
|
|
18
|
+
settle(outcome: { readonly cacheHit: boolean; readonly error?: unknown }): void;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Start reporting one `llm()` call, requested of `model`, under `span`. */
|
|
22
|
+
export function llmReport(span: Span, model: string, meter: RunMeter): LlmReport {
|
|
23
|
+
const startedAt = performance.now();
|
|
24
|
+
let provider = NO_PROVIDER;
|
|
25
|
+
let answeredBy = model;
|
|
26
|
+
const watched: RunMeter = {
|
|
27
|
+
...meter,
|
|
28
|
+
call: async (work: Promise<GenerateResult>): Promise<GenerateResult> => {
|
|
29
|
+
const result = await meter.call(work);
|
|
30
|
+
provider = result.provider ?? NO_PROVIDER;
|
|
31
|
+
answeredBy = result.model;
|
|
32
|
+
return result;
|
|
33
|
+
},
|
|
34
|
+
};
|
|
35
|
+
return {
|
|
36
|
+
meter: watched,
|
|
37
|
+
settle({ cacheHit, error }) {
|
|
38
|
+
// The RUN's usage — every attempt, repair turns included — not the last answer's.
|
|
39
|
+
const usage = meter.total()?.usage;
|
|
40
|
+
const inputTokens = usage?.inputTokens ?? 0;
|
|
41
|
+
const outputTokens = usage?.outputTokens ?? 0;
|
|
42
|
+
span.setAttributes({
|
|
43
|
+
[SEMCONV.genAiProviderName]: provider,
|
|
44
|
+
[SEMCONV.genAiResponseModel]: answeredBy,
|
|
45
|
+
[SEMCONV.genAiUsageInputTokens]: inputTokens,
|
|
46
|
+
[SEMCONV.genAiUsageOutputTokens]: outputTokens,
|
|
47
|
+
});
|
|
48
|
+
if (!subscribed('ai.llm')) return;
|
|
49
|
+
instrument('ai.llm', {
|
|
50
|
+
provider,
|
|
51
|
+
model: answeredBy,
|
|
52
|
+
inputTokens,
|
|
53
|
+
outputTokens,
|
|
54
|
+
cacheHit,
|
|
55
|
+
durationMs: performance.now() - startedAt,
|
|
56
|
+
...(error === undefined ? {} : { error }),
|
|
57
|
+
});
|
|
58
|
+
},
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* ONE model call an `agent()` makes — a turn, or a compaction's summary — as `ai.llm`: the token
|
|
64
|
+
* metric is per call, and an agent's spend would otherwise reach no subscriber. Emitted on both
|
|
65
|
+
* paths; a failed call reports the model it ASKED for, no provider, and no tokens.
|
|
66
|
+
*/
|
|
67
|
+
export async function reportedCall(
|
|
68
|
+
model: string,
|
|
69
|
+
call: Promise<GenerateResult>,
|
|
70
|
+
): Promise<GenerateResult> {
|
|
71
|
+
const startedAt = performance.now();
|
|
72
|
+
let result: GenerateResult;
|
|
73
|
+
try {
|
|
74
|
+
result = await call;
|
|
75
|
+
} catch (error) {
|
|
76
|
+
if (subscribed('ai.llm')) {
|
|
77
|
+
instrument('ai.llm', {
|
|
78
|
+
provider: NO_PROVIDER,
|
|
79
|
+
model,
|
|
80
|
+
inputTokens: 0,
|
|
81
|
+
outputTokens: 0,
|
|
82
|
+
cacheHit: false,
|
|
83
|
+
durationMs: performance.now() - startedAt,
|
|
84
|
+
error,
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
throw error;
|
|
88
|
+
}
|
|
89
|
+
if (subscribed('ai.llm')) {
|
|
90
|
+
instrument('ai.llm', {
|
|
91
|
+
provider: result.provider ?? NO_PROVIDER,
|
|
92
|
+
model: result.model,
|
|
93
|
+
inputTokens: result.usage.inputTokens,
|
|
94
|
+
outputTokens: result.usage.outputTokens,
|
|
95
|
+
cacheHit: false,
|
|
96
|
+
durationMs: performance.now() - startedAt,
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
return result;
|
|
100
|
+
}
|