@ultimat3/ai 24.0.0 → 25.1.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 +72 -34
- package/README.md +271 -69
- package/package.json +12 -12
- package/src/agent-facts.ts +9 -2
- package/src/agent-job.ts +41 -2
- package/src/agent.ts +30 -13
- package/src/budget.ts +41 -6
- package/src/content-blocks.ts +227 -0
- package/src/content-errors.ts +67 -0
- package/src/echo-provider.ts +102 -0
- package/src/embeddings.ts +30 -10
- package/src/errors.ts +17 -11
- package/src/eval-errors.ts +3 -1
- package/src/evals.ts +1 -1
- package/src/failure-disclosure.ts +90 -0
- package/src/gateway.ts +40 -16
- package/src/hive-pool.ts +24 -16
- package/src/hive-result.ts +4 -1
- package/src/hive.ts +6 -4
- package/src/index.ts +63 -24
- package/src/llm-cache.ts +1 -1
- package/src/llm.ts +15 -11
- package/src/model-resolve.ts +79 -0
- package/src/models.ts +125 -111
- package/src/openai-body.ts +13 -2
- package/src/openai-messages.ts +86 -15
- package/src/openai-provider.ts +28 -31
- package/src/openai-wire.ts +4 -1
- package/src/pg-vector-sql.ts +11 -7
- package/src/pg-vector.ts +14 -6
- package/src/prompt-fence.ts +128 -0
- package/src/prompt.ts +43 -13
- package/src/provider.ts +75 -112
- package/src/rag.ts +7 -6
- package/src/remote-embedder.ts +10 -5
- package/src/runtime.ts +7 -14
- package/src/scorers.ts +1 -1
- package/src/tool-idempotency.ts +79 -0
- package/src/tools.ts +38 -36
- package/src/vector-scope.ts +2 -2
- package/src/vector.ts +103 -37
- package/src/wire.ts +7 -2
- package/src/fix-line.evals.ts +0 -35
- package/src/fix-line.ts +0 -27
- package/src/openai-models.ts +0 -84
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
// `EchoProvider`: the deterministic `Provider` tests, evals and `x dev` without a key run on. Apart
|
|
2
|
+
// from ./provider, which owns the real Messages API, because a double and the socket it stands in
|
|
3
|
+
// for are two jobs — and the request half had reached its line ceiling holding both.
|
|
4
|
+
|
|
5
|
+
import { resolveModel } from './model-resolve';
|
|
6
|
+
import type { ModelId } from './models';
|
|
7
|
+
import { modelIds } from './models';
|
|
8
|
+
import type {
|
|
9
|
+
AiMessage,
|
|
10
|
+
GenerateRequest,
|
|
11
|
+
GenerateResult,
|
|
12
|
+
Provider,
|
|
13
|
+
StreamChunk,
|
|
14
|
+
TokenUsage,
|
|
15
|
+
} from './provider';
|
|
16
|
+
import { costOf, estimateTextTokens, estimateTokens, messageText } from './provider';
|
|
17
|
+
|
|
18
|
+
export interface EchoProviderInput {
|
|
19
|
+
/** Fixed replies keyed by the last user message, for eval fixtures. */
|
|
20
|
+
readonly replies?: Readonly<Record<string, string>>;
|
|
21
|
+
/** Fallback when no key matches. Defaults to echoing the last user message. */
|
|
22
|
+
readonly fallback?: (prompt: string) => string;
|
|
23
|
+
readonly tokensPerCall?: number;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** The deterministic test provider — the one way to build one; the class is a type only. */
|
|
27
|
+
export function echoProvider(config: EchoProviderInput = {}): EchoProvider {
|
|
28
|
+
return new EchoProvider(config);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Deterministic provider. Same input, same output, same usage — which is what makes an eval
|
|
33
|
+
* suite a test rather than a sample. Token counts are derived from length, so a budget test
|
|
34
|
+
* can assert a refusal without a network.
|
|
35
|
+
*/
|
|
36
|
+
export class EchoProvider implements Provider {
|
|
37
|
+
readonly name = 'echo';
|
|
38
|
+
/**
|
|
39
|
+
* A getter over the whole registry, not a snapshot: the test double has to serve whatever the
|
|
40
|
+
* test registered, and a field read at construction time would miss a model registered after.
|
|
41
|
+
*/
|
|
42
|
+
get models(): readonly ModelId[] {
|
|
43
|
+
return modelIds();
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
private readonly config: EchoProviderInput;
|
|
47
|
+
|
|
48
|
+
constructor(config: EchoProviderInput = {}) {
|
|
49
|
+
this.config = config;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
async generate(request: GenerateRequest): Promise<GenerateResult> {
|
|
53
|
+
// No model of its own: the double must not choose one for an app any more than the real
|
|
54
|
+
// providers do. The gateway resolves before it routes; a direct call names one or is refused.
|
|
55
|
+
const model = resolveModel('echo-provider', request.model);
|
|
56
|
+
const prompt = lastUserMessage(request.messages);
|
|
57
|
+
const text = this.fixedReply(prompt) ?? this.config.fallback?.(prompt) ?? prompt;
|
|
58
|
+
const usage: TokenUsage = {
|
|
59
|
+
inputTokens: this.config.tokensPerCall ?? estimateTokens({ ...request, model }),
|
|
60
|
+
outputTokens: estimateTextTokens(text),
|
|
61
|
+
cacheReadTokens: 0,
|
|
62
|
+
cacheWriteTokens: 0,
|
|
63
|
+
};
|
|
64
|
+
return {
|
|
65
|
+
model,
|
|
66
|
+
text,
|
|
67
|
+
toolCalls: [],
|
|
68
|
+
stopReason: 'end_turn',
|
|
69
|
+
stopDetails: undefined,
|
|
70
|
+
usage,
|
|
71
|
+
cost: costOf(model, usage),
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* The fixture reply for this prompt. `Object.hasOwn`, never `replies?.[prompt]`: the key is
|
|
77
|
+
* MESSAGE TEXT, so a prompt of `toString` read a function off the prototype chain and returned
|
|
78
|
+
* it as the model's answer — a double that answers with JS source is worse than one that cannot.
|
|
79
|
+
*/
|
|
80
|
+
private fixedReply(prompt: string): string | undefined {
|
|
81
|
+
const { replies } = this.config;
|
|
82
|
+
if (replies === undefined || !Object.hasOwn(replies, prompt)) return undefined;
|
|
83
|
+
return replies[prompt];
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
async *stream(request: GenerateRequest): AsyncIterable<StreamChunk> {
|
|
87
|
+
const result = await this.generate(request);
|
|
88
|
+
// One word per chunk: enough to exercise a consumer's assembly logic.
|
|
89
|
+
for (const word of result.text.split(' ')) {
|
|
90
|
+
if (word !== '') yield { type: 'text', text: `${word} ` };
|
|
91
|
+
}
|
|
92
|
+
yield { type: 'done', result };
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function lastUserMessage(messages: readonly AiMessage[]): string {
|
|
97
|
+
for (let i = messages.length - 1; i >= 0; i -= 1) {
|
|
98
|
+
const message = messages[i];
|
|
99
|
+
if (message !== undefined && message.role === 'user') return messageText(message);
|
|
100
|
+
}
|
|
101
|
+
return '';
|
|
102
|
+
}
|
package/src/embeddings.ts
CHANGED
|
@@ -64,6 +64,11 @@ export interface HashEmbedderInput {
|
|
|
64
64
|
readonly dimension?: number;
|
|
65
65
|
}
|
|
66
66
|
|
|
67
|
+
/** The hashing embedder — the one way to build one; the class is a type only. */
|
|
68
|
+
export function hashEmbedder(input: HashEmbedderInput = {}): HashEmbedder {
|
|
69
|
+
return new HashEmbedder(input);
|
|
70
|
+
}
|
|
71
|
+
|
|
67
72
|
/**
|
|
68
73
|
* Deterministic bag-of-words hashing embedder. Not semantic — two paraphrases share no
|
|
69
74
|
* vocabulary and land far apart — but it IS stable, dependency-free, and fast, which is
|
|
@@ -76,7 +81,7 @@ export class HashEmbedder implements Embedder {
|
|
|
76
81
|
|
|
77
82
|
constructor(input: HashEmbedderInput = {}) {
|
|
78
83
|
// Floored at 1: `new Float32Array(NaN)` is a vector of LENGTH ZERO, so every embedding is
|
|
79
|
-
// empty, `cosine` answers
|
|
84
|
+
// empty, `cosine` answers `NaN` for every pair, and the ranking collapses with nothing thrown —
|
|
80
85
|
// the silent relevance collapse this file's own header says the dimension exists to catch.
|
|
81
86
|
this.dimension = finiteCount('HashEmbedder', 'dimension', input.dimension ?? 256, 1);
|
|
82
87
|
}
|
|
@@ -87,19 +92,19 @@ export class HashEmbedder implements Embedder {
|
|
|
87
92
|
|
|
88
93
|
private one(text: string): Float32Array {
|
|
89
94
|
const vector = new Float32Array(this.dimension);
|
|
90
|
-
for (const token of
|
|
95
|
+
for (const token of wordTokens(text)) {
|
|
91
96
|
const slot = hashOf(token) % this.dimension;
|
|
92
97
|
// Signed accumulation: without it every vector is non-negative and cosine
|
|
93
98
|
// similarity compresses into a narrow band where nothing ranks apart.
|
|
94
99
|
const sign = hashOf(`${token}#sign`) % 2 === 0 ? 1 : -1;
|
|
95
100
|
vector[slot] = (vector[slot] ?? 0) + sign;
|
|
96
101
|
}
|
|
97
|
-
return
|
|
102
|
+
return normalizeVector(vector);
|
|
98
103
|
}
|
|
99
104
|
}
|
|
100
105
|
|
|
101
106
|
/** Lowercased word tokens. Punctuation is dropped; digits are kept (versions, ids). */
|
|
102
|
-
export function
|
|
107
|
+
export function wordTokens(text: string): readonly string[] {
|
|
103
108
|
return text.toLowerCase().match(/[a-z0-9]+/g) ?? [];
|
|
104
109
|
}
|
|
105
110
|
|
|
@@ -112,8 +117,8 @@ function hashOf(text: string): number {
|
|
|
112
117
|
return Number.parseInt(fingerprint(text).slice(0, 8), 16);
|
|
113
118
|
}
|
|
114
119
|
|
|
115
|
-
/** L2 normalise in place so
|
|
116
|
-
export function
|
|
120
|
+
/** L2 normalise in place, so every embedder hands a store unit vectors whatever its scale. */
|
|
121
|
+
export function normalizeVector(vector: Float32Array): Float32Array {
|
|
117
122
|
let sum = 0;
|
|
118
123
|
for (const value of vector) sum += value * value;
|
|
119
124
|
if (sum === 0) return vector;
|
|
@@ -122,10 +127,25 @@ export function normalize(vector: Float32Array): Float32Array {
|
|
|
122
127
|
return vector;
|
|
123
128
|
}
|
|
124
129
|
|
|
125
|
-
/**
|
|
130
|
+
/**
|
|
131
|
+
* Cosine similarity — pgvector's `1 - (a <=> b)`, computed the way pgvector computes it, so the dev
|
|
132
|
+
* store and production rank alike. A bare dot product was cosine only for unit vectors, and a
|
|
133
|
+
* store accepts whatever an app upserts. A zero-norm side is `0 / 0`: `NaN`, as pgvector answers,
|
|
134
|
+
* never a `0` that would rank a vector with no direction above every opposed one. Clamped to
|
|
135
|
+
* [-1, 1] as pgvector clamps, so float error never reports a similarity past identical.
|
|
136
|
+
*/
|
|
126
137
|
export function cosine(a: Float32Array, b: Float32Array): number {
|
|
127
|
-
let
|
|
138
|
+
let dot = 0;
|
|
139
|
+
let normA = 0;
|
|
140
|
+
let normB = 0;
|
|
128
141
|
const length = Math.min(a.length, b.length);
|
|
129
|
-
for (let i = 0; i < length; i += 1)
|
|
130
|
-
|
|
142
|
+
for (let i = 0; i < length; i += 1) {
|
|
143
|
+
const x = a[i] ?? 0;
|
|
144
|
+
const y = b[i] ?? 0;
|
|
145
|
+
dot += x * y;
|
|
146
|
+
normA += x * x;
|
|
147
|
+
normB += y * y;
|
|
148
|
+
}
|
|
149
|
+
const similarity = dot / Math.sqrt(normA * normB);
|
|
150
|
+
return similarity > 1 ? 1 : similarity < -1 ? -1 : similarity;
|
|
131
151
|
}
|
package/src/errors.ts
CHANGED
|
@@ -28,6 +28,8 @@ export const AI_ERROR_CODES = [
|
|
|
28
28
|
'X_VECTOR_SCOPE_WIDENED',
|
|
29
29
|
'X_AI_EMBEDDER_INVALID',
|
|
30
30
|
'X_VECTOR_UNSCOPED',
|
|
31
|
+
'X_AI_CONTENT_UNSUPPORTED',
|
|
32
|
+
'X_AI_MODEL_UNRESOLVED',
|
|
31
33
|
] as const;
|
|
32
34
|
|
|
33
35
|
export type AiErrorCode = (typeof AI_ERROR_CODES)[number];
|
|
@@ -57,6 +59,8 @@ export const AI_ERROR_TITLES: Readonly<Record<AiErrorCode, string>> = {
|
|
|
57
59
|
X_VECTOR_SCOPE_WIDENED: 'a derived vector scope tried to leave its tenant',
|
|
58
60
|
X_AI_EMBEDDER_INVALID: 'an Embedder returned fewer vectors than texts it was given',
|
|
59
61
|
X_VECTOR_UNSCOPED: 'a vector store was read with no tenant bound inside an org request',
|
|
62
|
+
X_AI_CONTENT_UNSUPPORTED: 'an image or document block the role, model or wire format cannot take',
|
|
63
|
+
X_AI_MODEL_UNRESOLVED: 'a model call named no model',
|
|
60
64
|
};
|
|
61
65
|
|
|
62
66
|
// Titles must be registered for `format()` to render the contract's first line. Unconditional and
|
|
@@ -130,25 +134,27 @@ export class AiGatewayMissingError extends UltimateError {
|
|
|
130
134
|
constructor(input: { prompt: string }) {
|
|
131
135
|
super({
|
|
132
136
|
code: 'X_AI_GATEWAY_MISSING',
|
|
133
|
-
|
|
134
|
-
|
|
137
|
+
// The provider is the app's choice, so no vendor is the default (M12): the fix is the shape
|
|
138
|
+
// and the cause names what `provider` is — one of the peers, or the app's own.
|
|
139
|
+
cause: `an llm action on prompt "${input.prompt}" ran before any gateway was configured (provider: anthropicProvider({ models }) for the Anthropic Messages format, openAiProvider({ baseUrl, models }) for the OpenAI chat-completions format on any compatible server, or your own Provider)`,
|
|
140
|
+
fix: 'configureAi({ gateway: providerGateway({ providers: [provider] }) }) at boot',
|
|
135
141
|
});
|
|
136
142
|
}
|
|
137
143
|
}
|
|
138
144
|
|
|
139
145
|
/**
|
|
140
|
-
* A model id
|
|
141
|
-
*
|
|
142
|
-
*
|
|
143
|
-
*
|
|
144
|
-
* it is refused HERE, at the first read of the spec, instead of by making a right one impossible.
|
|
146
|
+
* A model id the app never registered. The framework registers none, so every id a call names —
|
|
147
|
+
* any vendor's — is one the app's own `registerModel` wrote: priced, reserved and recorded by the
|
|
148
|
+
* app's numbers, never by a list this package shipped. Refused HERE, at the first read of the spec
|
|
149
|
+
* (`costOf`, the budget's estimate, the request builders), before anything is sent or charged.
|
|
145
150
|
*/
|
|
146
151
|
export class AiModelUnknownError extends UltimateError {
|
|
147
152
|
constructor(input: { model: string; registered: readonly string[] }) {
|
|
148
153
|
super({
|
|
149
154
|
code: 'X_AI_MODEL_UNKNOWN',
|
|
150
155
|
cause:
|
|
151
|
-
`model "${input.model}" has no registered spec, so nothing can price it ` +
|
|
156
|
+
`model "${input.model}" has no registered spec, so nothing can price or budget it — ` +
|
|
157
|
+
`the framework registers no model of its own ` +
|
|
152
158
|
`(registered: ${input.registered.length > 0 ? input.registered.join(', ') : 'none'})`,
|
|
153
159
|
// The `errors` gate blanks every interpolation, so the literal half alone has to name the
|
|
154
160
|
// call. Which ids ARE registered is a fact of the failure, and cause is where facts live.
|
|
@@ -261,8 +267,8 @@ export class LlmRefusedError extends UltimateError {
|
|
|
261
267
|
prompt: string;
|
|
262
268
|
model: string;
|
|
263
269
|
/**
|
|
264
|
-
* A
|
|
265
|
-
*
|
|
270
|
+
* A model the APP registered, in the same family, MORE capable than the one that refused —
|
|
271
|
+
* or `undefined` when the app registered none above it. Retrying a refusal on a weaker model is
|
|
266
272
|
* the one retry that cannot help, so the fix line drops the suggestion rather than inventing
|
|
267
273
|
* a downgrade.
|
|
268
274
|
*/
|
|
@@ -278,7 +284,7 @@ export class LlmRefusedError extends UltimateError {
|
|
|
278
284
|
`${input.explanation === undefined ? '' : `: ${input.explanation}`}`,
|
|
279
285
|
fix:
|
|
280
286
|
input.alternative === undefined
|
|
281
|
-
? `edit the template in definePrompt('${input.prompt}') and bump its version — no
|
|
287
|
+
? `edit the template in definePrompt('${input.prompt}') and bump its version — no model your app registered in its family is more capable than '${input.model}'`
|
|
282
288
|
: `set model: '${input.alternative}' on the llm() declaration, or edit the template in definePrompt('${input.prompt}') and bump its version`,
|
|
283
289
|
meta: { model: input.model, category: input.category },
|
|
284
290
|
});
|
package/src/eval-errors.ts
CHANGED
|
@@ -61,7 +61,9 @@ export class EvalBaselineInvalidError extends UltimateError {
|
|
|
61
61
|
super({
|
|
62
62
|
code: 'X_EVAL_BASELINE_INVALID',
|
|
63
63
|
cause: `the recorded baseline ${input.path} ${input.problem}`,
|
|
64
|
-
|
|
64
|
+
// The command is the record run; the path is named behind the `#`, where it is read and
|
|
65
|
+
// never run — `x test eval` takes no path, and a file name may carry `$(…)` (sweep 1c).
|
|
66
|
+
fix: `ULTIMATE_EVAL_RECORD=1 x test eval # re-records ${input.path}`,
|
|
65
67
|
});
|
|
66
68
|
}
|
|
67
69
|
}
|
package/src/evals.ts
CHANGED
|
@@ -187,7 +187,7 @@ async function runEval<V extends PromptVars>(
|
|
|
187
187
|
...(input.prompt.system !== undefined ? { system: input.prompt.system } : {}),
|
|
188
188
|
...(input.prompt.model !== undefined ? { model: input.prompt.model } : {}),
|
|
189
189
|
...(input.prompt.effort !== undefined ? { effort: input.prompt.effort } : {}),
|
|
190
|
-
// `thinking` too, and not because it is tidy: `
|
|
190
|
+
// `thinking` too, and not because it is tidy: `promptHash` covers it, so the baseline is
|
|
191
191
|
// filed under a hash describing a configuration the measurement did not use. `llm()` and
|
|
192
192
|
// `agent()` have always sent both.
|
|
193
193
|
...(input.prompt.thinking !== undefined ? { thinking: input.prompt.thinking } : {}),
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
// The one rule for what of a thrown value a remote reader may see: an LLM tool result and a hive
|
|
2
|
+
// member's reason both reach someone outside this process, so both ask this file — and the half
|
|
3
|
+
// it withholds goes to the log as content-free facts (code, name, frames) — never its text.
|
|
4
|
+
|
|
5
|
+
import type { Logger } from '@ultimat3/core';
|
|
6
|
+
import { hasPublicCause, isUltimateError, stringField } from '@ultimat3/core';
|
|
7
|
+
import { statusFor } from '@ultimat3/http';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* What may be shown. `code` is absent for an uncoded throw — a foreign `.message` is the server's
|
|
11
|
+
* (a pg `Key (email)=(…)`), so nothing of it is carried. `cause` is absent when it is withheld:
|
|
12
|
+
* the caller substitutes its own fixed sentence, because who is reading decides the wording.
|
|
13
|
+
*/
|
|
14
|
+
export interface DisclosedFailure {
|
|
15
|
+
readonly code: string | undefined;
|
|
16
|
+
readonly cause: string | undefined;
|
|
17
|
+
readonly fix: string | undefined;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Read STRUCTURALLY and totally — `stringField`, never `typeof e.code` or `.message`: the value is
|
|
22
|
+
* whatever an app's handler, its driver or its SDK threw, so each read is a getter call or a
|
|
23
|
+
* `Proxy` trap, and this runs inside a catch block with nothing left to answer if a probe raises.
|
|
24
|
+
*
|
|
25
|
+
* A 5xx code's cause is shown only when core's `hasPublicCause` allows it — the rule a production
|
|
26
|
+
* problem document follows (`@ultimat3/http`'s `toProblem`). Hidden, the developer's `fix` goes
|
|
27
|
+
* with it (a driver writes it from the statement it failed on); a `callerFix` an `UltimateError`
|
|
28
|
+
* built in this process is authored for a remote reader and stays.
|
|
29
|
+
*/
|
|
30
|
+
export function discloseFailure(
|
|
31
|
+
error: unknown,
|
|
32
|
+
logger: Logger | undefined,
|
|
33
|
+
where: string,
|
|
34
|
+
): DisclosedFailure {
|
|
35
|
+
const code = stringField(error, 'code');
|
|
36
|
+
if (code === undefined) return withheld(error, logger, where, undefined, undefined);
|
|
37
|
+
if (statusFor(code) >= 500 && !hasPublicCause(code)) {
|
|
38
|
+
const callerFix = isUltimateError(error) ? stringField(error, 'callerFix') : undefined;
|
|
39
|
+
return withheld(error, logger, where, code, callerFix);
|
|
40
|
+
}
|
|
41
|
+
const fix = stringField(error, 'fix');
|
|
42
|
+
return {
|
|
43
|
+
code,
|
|
44
|
+
cause: stringField(error, 'cause') ?? 'unknown',
|
|
45
|
+
fix: fix === undefined || fix === '' ? undefined : fix,
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* A stack's FRAMES and nothing else: every line that is a frame (` at …`), none that is not.
|
|
51
|
+
* The message is the first line — and, when it spans several, the lines after it until the first
|
|
52
|
+
* frame — so keeping only frame-shaped lines drops all of it, whatever it held. A frame is code
|
|
53
|
+
* position, which carries no caller data.
|
|
54
|
+
*/
|
|
55
|
+
const FRAME = /^\s+at\s/;
|
|
56
|
+
|
|
57
|
+
function framesOf(error: unknown): string | null {
|
|
58
|
+
const stack = stringField(error, 'stack');
|
|
59
|
+
if (stack === undefined) return null;
|
|
60
|
+
const lines = stack.split('\n');
|
|
61
|
+
// Skip to the first frame line that FOLLOWS the message: a message may itself contain text
|
|
62
|
+
// shaped like a frame, and only lines after the message block are the stack.
|
|
63
|
+
const message = stringField(error, 'message') ?? '';
|
|
64
|
+
const skip = message === '' ? 1 : message.split('\n').length;
|
|
65
|
+
const frames = lines.slice(skip).filter((line) => FRAME.test(line));
|
|
66
|
+
return frames.length === 0 ? null : frames.join('\n');
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* The withheld half, logged as FACTS ONLY — the code, the error's name, and its frames. Never the
|
|
71
|
+
* message and never the cause: the logger redacts by field NAME, not by content, so a pg
|
|
72
|
+
* `Key (email)=(ceo@corp.com)` logged as text reached the logs whole. What identifies the failure
|
|
73
|
+
* is the code and where it was thrown, both of which survive this.
|
|
74
|
+
*/
|
|
75
|
+
function withheld(
|
|
76
|
+
error: unknown,
|
|
77
|
+
logger: Logger | undefined,
|
|
78
|
+
where: string,
|
|
79
|
+
code: string | undefined,
|
|
80
|
+
fix: string | undefined,
|
|
81
|
+
): DisclosedFailure {
|
|
82
|
+
logger?.error('ai.failure.withheld', {
|
|
83
|
+
where,
|
|
84
|
+
code: code ?? 'uncoded',
|
|
85
|
+
// `name`, read structurally: what kind of throw it was (`Error`, `TypeError`, a driver's own).
|
|
86
|
+
name: stringField(error, 'name') ?? typeof error,
|
|
87
|
+
frames: framesOf(error),
|
|
88
|
+
});
|
|
89
|
+
return { code, cause: undefined, fix };
|
|
90
|
+
}
|
package/src/gateway.ts
CHANGED
|
@@ -17,10 +17,16 @@ import {
|
|
|
17
17
|
} from '@ultimat3/core';
|
|
18
18
|
import type { Money } from '@ultimat3/money';
|
|
19
19
|
import type { BudgetKeys, BudgetLimits, BudgetStore } from './budget';
|
|
20
|
-
import {
|
|
20
|
+
import {
|
|
21
|
+
assertFiniteLimits,
|
|
22
|
+
BudgetLedger,
|
|
23
|
+
currentBudget,
|
|
24
|
+
estimateSpend,
|
|
25
|
+
withBudget,
|
|
26
|
+
} from './budget';
|
|
21
27
|
import { AiProviderUnavailableError } from './errors';
|
|
28
|
+
import { resolveModel } from './model-resolve';
|
|
22
29
|
import type { ModelId } from './models';
|
|
23
|
-
import { DEFAULT_MODEL } from './models';
|
|
24
30
|
import type { GenerateRequest, GenerateResult, Provider, StreamChunk } from './provider';
|
|
25
31
|
|
|
26
32
|
/**
|
|
@@ -34,7 +40,7 @@ export interface GatewayCache {
|
|
|
34
40
|
|
|
35
41
|
/**
|
|
36
42
|
* A gateway's retry budget. NOT core's `RetryPolicy`, and deliberately still its own declaration:
|
|
37
|
-
* these three field names are what an app writes in `
|
|
43
|
+
* these three field names are what an app writes in `providerGateway({ retry })`, so renaming them
|
|
38
44
|
* onto `base`/`max`/`jitter` would break every caller for no behaviour. What WAS a duplicate is the
|
|
39
45
|
* arithmetic, and that is gone — `backoffMs` is core's `backoffDelay` with this shape mapped onto
|
|
40
46
|
* it, so there is one curve in the framework and one place a jitter bug can live.
|
|
@@ -47,12 +53,16 @@ export interface RetryPolicy {
|
|
|
47
53
|
readonly maxDelayMs: number;
|
|
48
54
|
}
|
|
49
55
|
|
|
50
|
-
export const
|
|
56
|
+
export const DEFAULT_GATEWAY_RETRY: RetryPolicy = {
|
|
57
|
+
attempts: 3,
|
|
58
|
+
baseDelayMs: 500,
|
|
59
|
+
maxDelayMs: 8_000,
|
|
60
|
+
};
|
|
51
61
|
|
|
52
62
|
/** The one code `attempt` may collect into. Every other coded refusal is the caller's answer. */
|
|
53
63
|
const PROVIDER_UNAVAILABLE = 'X_AI_PROVIDER_UNAVAILABLE';
|
|
54
64
|
|
|
55
|
-
export interface
|
|
65
|
+
export interface ProviderGatewayInput {
|
|
56
66
|
/** Tried in order for a given model. First provider that lists the model wins. */
|
|
57
67
|
readonly providers: readonly Provider[];
|
|
58
68
|
readonly budget?: BudgetLimits;
|
|
@@ -72,6 +82,13 @@ export interface CreateGatewayInput {
|
|
|
72
82
|
}
|
|
73
83
|
|
|
74
84
|
export interface Gateway {
|
|
85
|
+
/**
|
|
86
|
+
* The model a call with none declared runs on — `providerGateway({ defaultModel })`. Read by
|
|
87
|
+
* `llm()` and `agent()` after the declaration and its prompt, so the app's choice is the one
|
|
88
|
+
* fallback; absent, a call naming no model is refused (`X_AI_MODEL_UNRESOLVED`) — never routed to
|
|
89
|
+
* a vendor the framework picked.
|
|
90
|
+
*/
|
|
91
|
+
readonly defaultModel?: ModelId | undefined;
|
|
75
92
|
generate(request: GenerateRequest): Promise<GenerateResult>;
|
|
76
93
|
stream(request: GenerateRequest): AsyncIterable<StreamChunk>;
|
|
77
94
|
/** Open a budget scope. Nested gateway calls inside `fn` share one ledger. */
|
|
@@ -87,36 +104,43 @@ export interface Gateway {
|
|
|
87
104
|
callLedger(keys: BudgetKeys): BudgetLedger;
|
|
88
105
|
}
|
|
89
106
|
|
|
90
|
-
export function
|
|
107
|
+
export function providerGateway(input: ProviderGatewayInput): Gateway {
|
|
91
108
|
return new GatewayImpl(input);
|
|
92
109
|
}
|
|
93
110
|
|
|
94
111
|
class GatewayImpl implements Gateway {
|
|
95
|
-
private readonly config:
|
|
112
|
+
private readonly config: ProviderGatewayInput;
|
|
96
113
|
private readonly retry: RetryPolicy;
|
|
97
114
|
private readonly sleep: (ms: number) => Promise<void>;
|
|
98
115
|
/** Left `undefined` rather than defaulted, so `backoffDelay` owns the one fallback to `Math.random`. */
|
|
99
116
|
private readonly random: Random | undefined;
|
|
100
117
|
|
|
101
|
-
|
|
118
|
+
get defaultModel(): ModelId | undefined {
|
|
119
|
+
return this.config.defaultModel;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
constructor(config: ProviderGatewayInput) {
|
|
102
123
|
this.config = config;
|
|
103
|
-
this.retry = config.retry ??
|
|
124
|
+
this.retry = config.retry ?? DEFAULT_GATEWAY_RETRY;
|
|
104
125
|
// `attempts` is the retry loop's only exit condition and nothing screened it: `attempt <= NaN`
|
|
105
126
|
// is false on the first comparison, so `attempt()` calls no provider at all and raises
|
|
106
127
|
// `X_AI_PROVIDER_UNAVAILABLE` with an EMPTY attempt list — measured, "no provider could serve
|
|
107
|
-
// model
|
|
128
|
+
// model <id> ()" for a provider that was never asked. A floor of 1 because the field
|
|
108
129
|
// is documented as total attempts INCLUDING the first, so zero of them is not a policy.
|
|
109
130
|
// `baseDelayMs` and `maxDelayMs` are deliberately not screened here: `backoffDelay` refuses a
|
|
110
131
|
// non-finite one already, and a NEGATIVE base is clamped to a zero wait on purpose
|
|
111
132
|
// (`gateway-backoff.test.ts` pins it), which a count check here would start refusing.
|
|
112
|
-
finiteCount('
|
|
133
|
+
finiteCount('gateway', 'retry.attempts', this.retry.attempts, 1);
|
|
134
|
+
// Screened here, under the config's own key, rather than by the ledger `callLedger` builds:
|
|
135
|
+
// that refused on the first CALL and named `the AI budget`, not the `gateway` to edit.
|
|
136
|
+
if (config.budget !== undefined) assertFiniteLimits(config.budget, 'gateway', 'budget.');
|
|
113
137
|
this.sleep = config.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
|
|
114
138
|
this.random = config.random;
|
|
115
139
|
}
|
|
116
140
|
|
|
117
141
|
/**
|
|
118
142
|
* `budget` held with no scope open. It was enforced ONLY inside `scope()`, which nothing in the
|
|
119
|
-
* framework calls — so `
|
|
143
|
+
* framework calls — so `providerGateway({ budget: { request: 100 } })` capped no call any app made.
|
|
120
144
|
* A raw `generate` / `stream` outside any scope passes no keys: it has no caller to count.
|
|
121
145
|
*/
|
|
122
146
|
callLedger(keys: BudgetKeys): BudgetLedger {
|
|
@@ -139,10 +163,10 @@ class GatewayImpl implements Gateway {
|
|
|
139
163
|
}
|
|
140
164
|
|
|
141
165
|
async generate(request: GenerateRequest): Promise<GenerateResult> {
|
|
142
|
-
const model = request.model
|
|
166
|
+
const model = resolveModel('gateway', request.model, this.config.defaultModel);
|
|
143
167
|
const resolved: GenerateRequest = { ...request, model, maxTokens: ceilingOf(request) };
|
|
144
168
|
|
|
145
|
-
const cacheKey =
|
|
169
|
+
const cacheKey = promptCacheKey(resolved);
|
|
146
170
|
const cached = await this.config.cache?.get(cacheKey);
|
|
147
171
|
if (cached !== undefined) {
|
|
148
172
|
// A cache hit costs nothing, so it is deliberately NOT debited from the budget.
|
|
@@ -186,7 +210,7 @@ class GatewayImpl implements Gateway {
|
|
|
186
210
|
}
|
|
187
211
|
|
|
188
212
|
async *stream(request: GenerateRequest): AsyncIterable<StreamChunk> {
|
|
189
|
-
const model = request.model
|
|
213
|
+
const model = resolveModel('gateway', request.model, this.config.defaultModel);
|
|
190
214
|
const resolved: GenerateRequest = { ...request, model, maxTokens: ceilingOf(request) };
|
|
191
215
|
|
|
192
216
|
// Routed BEFORE the reservation, not after it. `providerFor` throws for a registered model no
|
|
@@ -382,7 +406,7 @@ export function isRetryable(error: unknown): boolean {
|
|
|
382
406
|
* after the schema changed. Core's `fingerprint`, so key order never splits one request in two and
|
|
383
407
|
* the width is the one every other sharing key in the framework has.
|
|
384
408
|
*/
|
|
385
|
-
export function
|
|
409
|
+
export function promptCacheKey(request: GenerateRequest): string {
|
|
386
410
|
return fingerprint({
|
|
387
411
|
model: request.model,
|
|
388
412
|
system: request.system ?? '',
|
package/src/hive-pool.ts
CHANGED
|
@@ -5,7 +5,8 @@
|
|
|
5
5
|
// happens to the siblings when one throws". Nothing here knows what a model is.
|
|
6
6
|
|
|
7
7
|
import type { Ctx } from '@ultimat3/core';
|
|
8
|
-
import {
|
|
8
|
+
import { withChildContext } from '@ultimat3/core';
|
|
9
|
+
import { discloseFailure } from './failure-disclosure';
|
|
9
10
|
import type { HiveMember, HiveMemberError } from './hive-result';
|
|
10
11
|
import { SKIPPED_ABORTED, SKIPPED_NO_INPUT } from './hive-result';
|
|
11
12
|
|
|
@@ -57,7 +58,7 @@ export async function runPool<I, O>(input: PoolInput<I, O>): Promise<readonly Hi
|
|
|
57
58
|
);
|
|
58
59
|
members[index] = { status: 'ok', index, value };
|
|
59
60
|
} catch (error) {
|
|
60
|
-
members[index] = { status: 'failed', index, ...failureOf(error) };
|
|
61
|
+
members[index] = { status: 'failed', index, ...failureOf(error, ctx, index) };
|
|
61
62
|
if (input.onMemberError === 'abort') controller.abort();
|
|
62
63
|
}
|
|
63
64
|
}
|
|
@@ -72,19 +73,26 @@ export async function runPool<I, O>(input: PoolInput<I, O>): Promise<readonly Hi
|
|
|
72
73
|
}
|
|
73
74
|
|
|
74
75
|
/**
|
|
75
|
-
* What a member
|
|
76
|
-
*
|
|
77
|
-
*
|
|
76
|
+
* What the caller may read of a member's throw, as two data fields — never as an error's `cause:`.
|
|
77
|
+
* The reason ships in the hive action's 200 answer, so it is `discloseFailure`'s verdict, the one
|
|
78
|
+
* the tool result gets: a 5xx cause or a foreign `.message` (a pg `Key (email)=(…)`) is withheld
|
|
79
|
+
* and logged whole. A throw with no code gets `'unknown'` rather than an invented `X_` code: a code
|
|
80
|
+
* nothing declares is a code no `x errors explain` can answer.
|
|
78
81
|
*/
|
|
79
|
-
function failureOf(
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
//
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
}
|
|
89
|
-
return { code: 'unknown', reason: 'the member threw a value that is not an Error' };
|
|
82
|
+
function failureOf(
|
|
83
|
+
error: unknown,
|
|
84
|
+
ctx: Ctx,
|
|
85
|
+
index: number,
|
|
86
|
+
): { readonly code: string; readonly reason: string } {
|
|
87
|
+
// `fix` is not carried: `reason` has always been the cause alone, and a member's caller acts on
|
|
88
|
+
// the code. What `discloseFailure` withholds stays withheld either way.
|
|
89
|
+
const shown = discloseFailure(error, ctx.logger, `hive member ${index}`);
|
|
90
|
+
return { code: shown.code ?? 'unknown', reason: shown.cause ?? HIDDEN_MEMBER_CAUSE };
|
|
90
91
|
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* What a caller reads in place of a reason it may not see. Fixed, so nothing of the throw rides on
|
|
95
|
+
* it — the hive's answer goes wherever its caller sends it, a model's context included.
|
|
96
|
+
*/
|
|
97
|
+
export const HIDDEN_MEMBER_CAUSE =
|
|
98
|
+
'the member failed inside the server; the details are withheld from this answer; the server log records its code and where it failed';
|
package/src/hive-result.ts
CHANGED
|
@@ -23,7 +23,10 @@ export type HiveMember<O> =
|
|
|
23
23
|
| {
|
|
24
24
|
readonly status: 'failed';
|
|
25
25
|
readonly index: number;
|
|
26
|
-
/**
|
|
26
|
+
/**
|
|
27
|
+
* The code the member threw — read structurally, so a foreign throw carrying a string `code`
|
|
28
|
+
* (a driver's SQLSTATE) reports it — or `'unknown'` for a throw with none.
|
|
29
|
+
*/
|
|
27
30
|
readonly code: string;
|
|
28
31
|
readonly reason: string;
|
|
29
32
|
}
|
package/src/hive.ts
CHANGED
|
@@ -5,8 +5,8 @@
|
|
|
5
5
|
* `@ultimat3/core`, and it is a list rather than a sentence because the ordinal a file writes for
|
|
6
6
|
* itself is wrong the day the next one lands and no file can see the others. A fan-out is still
|
|
7
7
|
* one server-authoritative operation with an input schema, an output schema and a policy, so this
|
|
8
|
-
* returns an `action` and inherits `.
|
|
9
|
-
* and its manifest row without a line here.
|
|
8
|
+
* returns an `action` and inherits `.openapi()`, `.client()`, `.job()`, `.contract()`, its MCP
|
|
9
|
+
* tool and its manifest row without a line here.
|
|
10
10
|
*
|
|
11
11
|
* It exists because the alternative is a hand-rolled `Promise.all` over `agent()` calls, and that
|
|
12
12
|
* loop gets four things wrong every time: it takes the actor from somewhere other than the request,
|
|
@@ -20,7 +20,7 @@ import type { Ctx } from '@ultimat3/core';
|
|
|
20
20
|
import { finiteCount, throwIfAborted, withSpan } from '@ultimat3/core';
|
|
21
21
|
import type { AnySchema, InferInput, InferOutput, StandardSchemaV1 } from '@ultimat3/schema';
|
|
22
22
|
import type { BudgetLimits } from './budget';
|
|
23
|
-
import { BudgetLedger, budgetKeysFor, currentBudget, withBudget } from './budget';
|
|
23
|
+
import { BudgetLedger, budgetKeysFor, currentBudget, screenedMoney, withBudget } from './budget';
|
|
24
24
|
import { HiveEmptyError } from './hive-errors';
|
|
25
25
|
import { runPool } from './hive-pool';
|
|
26
26
|
import type { HiveMember, HiveMemberError, HiveOutput, HiveResult } from './hive-result';
|
|
@@ -191,7 +191,9 @@ function limitsOf<
|
|
|
191
191
|
...(budget?.tokensIn === undefined
|
|
192
192
|
? {}
|
|
193
193
|
: { tokensIn: finiteCount(SUBJECT, 'budget.tokensIn', budget.tokensIn) }),
|
|
194
|
-
...(budget?.costPerCall === undefined
|
|
194
|
+
...(budget?.costPerCall === undefined
|
|
195
|
+
? {}
|
|
196
|
+
: { costPerCall: screenedMoney(SUBJECT, 'budget.costPerCall', budget.costPerCall) }),
|
|
195
197
|
// The ledger's `request` scope accumulates across every call made under it, which for a hive
|
|
196
198
|
// under `withBudget` is every member's every turn.
|
|
197
199
|
...(budget?.tokensPerRun === undefined
|