@ultimat3/ai 29.4.0 → 30.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 +63 -90
- package/README.md +125 -8
- package/package.json +23 -11
- package/src/agent-checkpoint.ts +44 -0
- package/src/agent-compact-def.ts +11 -0
- package/src/agent-compact-measure.ts +38 -0
- package/src/agent-compact-request.ts +100 -0
- package/src/agent-compact-text.ts +95 -0
- package/src/agent-compact.ts +75 -50
- package/src/agent-continue.ts +15 -3
- package/src/agent-def.ts +9 -0
- package/src/agent-errors.ts +12 -2
- package/src/agent-facts.ts +8 -0
- package/src/agent-send.ts +82 -0
- package/src/agent-stream.ts +76 -0
- package/src/agent.ts +71 -21
- package/src/answer-errors.ts +1 -1
- package/src/anthropic-provider.ts +291 -0
- package/src/content-errors.ts +1 -1
- package/src/context-overflow.ts +86 -0
- package/src/error-codes.ts +95 -0
- package/src/errors-retry.ts +8 -4
- package/src/errors.ts +6 -87
- package/src/eval-agent.ts +100 -0
- package/src/eval-errors.ts +31 -5
- package/src/evals.ts +125 -45
- package/src/gateway-providers.ts +215 -0
- package/src/gateway.ts +63 -153
- package/src/hive-errors.ts +1 -1
- package/src/in-process-tool.ts +20 -11
- package/src/index.ts +31 -7
- package/src/llm.ts +39 -8
- package/src/model-resolve.ts +7 -1
- package/src/openai-body.ts +12 -5
- package/src/openai-provider.ts +28 -8
- package/src/openai-wire.ts +11 -7
- package/src/prompt-cache-rows.ts +82 -0
- package/src/prompt-cache.ts +24 -2
- package/src/provider.ts +15 -274
- package/src/remote-embedder.ts +38 -11
- package/src/run-meter.ts +40 -2
- package/src/runtime.ts +38 -3
- package/src/scorers.ts +35 -1
- package/src/scripted-provider.ts +146 -0
- package/src/tools-gate.ts +46 -0
- package/src/tools.ts +101 -27
- package/src/wire.ts +12 -4
package/src/evals.ts
CHANGED
|
@@ -12,6 +12,8 @@
|
|
|
12
12
|
// to an exact prompt rather than "whatever was in main that day".
|
|
13
13
|
|
|
14
14
|
import { finiteCount } from '@ultimat3/core';
|
|
15
|
+
import type { AgentEvalInput, CaseOutcome } from './eval-agent';
|
|
16
|
+
import { agentPrompt, runAgentCase } from './eval-agent';
|
|
15
17
|
import type { EvalBaseline, Regression } from './eval-baseline';
|
|
16
18
|
import {
|
|
17
19
|
baselinePath,
|
|
@@ -25,9 +27,11 @@ import { EvalBaselineMissingError, EvalThresholdError } from './eval-errors';
|
|
|
25
27
|
import type { Gateway } from './gateway';
|
|
26
28
|
import type { Prompt, PromptVars } from './prompt';
|
|
27
29
|
import { describePrompts } from './prompt';
|
|
28
|
-
import type { Scorer } from './scorers';
|
|
30
|
+
import type { EvalRun, Scorer } from './scorers';
|
|
29
31
|
import { clampScore } from './scorers';
|
|
30
32
|
|
|
33
|
+
export type { AgentEvalCase, AgentEvalInput, EvaluatedAgent } from './eval-agent';
|
|
34
|
+
|
|
31
35
|
/** One case: the variables to render with, plus what a good answer looks like. */
|
|
32
36
|
export interface EvalCase<V extends PromptVars = PromptVars> {
|
|
33
37
|
readonly name: string;
|
|
@@ -36,7 +40,8 @@ export interface EvalCase<V extends PromptVars = PromptVars> {
|
|
|
36
40
|
readonly expected?: string;
|
|
37
41
|
}
|
|
38
42
|
|
|
39
|
-
|
|
43
|
+
/** The prompt arm: one rendered request per case, no tools — for a prompt `llm()` sends. */
|
|
44
|
+
export interface PromptEvalInput<V extends PromptVars = PromptVars> {
|
|
40
45
|
readonly name: string;
|
|
41
46
|
readonly prompt: Prompt<V>;
|
|
42
47
|
readonly cases: readonly EvalCase<V>[];
|
|
@@ -52,11 +57,24 @@ export interface DefineEvalInput<V extends PromptVars = PromptVars> {
|
|
|
52
57
|
readonly maxTokens?: number;
|
|
53
58
|
}
|
|
54
59
|
|
|
60
|
+
/**
|
|
61
|
+
* Either arm. `agent` runs the whole loop — the agent's tools (as the eval's doubles) and turns —
|
|
62
|
+
* because a prompt that says "answer through the respond tool" measured in a request offering no
|
|
63
|
+
* tool describes something the agent never runs. Narrow with `'prompt' in input`.
|
|
64
|
+
*/
|
|
65
|
+
export type DefineEvalInput<V extends PromptVars = PromptVars> =
|
|
66
|
+
| PromptEvalInput<V>
|
|
67
|
+
| AgentEvalInput;
|
|
68
|
+
|
|
55
69
|
export interface CaseResult {
|
|
56
70
|
readonly case: string;
|
|
57
71
|
readonly output: string;
|
|
58
72
|
readonly score: number;
|
|
59
73
|
readonly perScorer: Readonly<Record<string, number>>;
|
|
74
|
+
/** Agent arm: what the run did — its turns and the tools it called, in order. */
|
|
75
|
+
readonly run?: { readonly turns: number; readonly toolCalls: readonly string[] };
|
|
76
|
+
/** Agent arm: the code a failed run threw. The case scored 0; the suite went on. */
|
|
77
|
+
readonly failed?: string;
|
|
60
78
|
}
|
|
61
79
|
|
|
62
80
|
export interface EvalResult {
|
|
@@ -64,6 +82,8 @@ export interface EvalResult {
|
|
|
64
82
|
readonly promptRef: string;
|
|
65
83
|
/** The prompt content hash. A score means nothing without it. */
|
|
66
84
|
readonly promptHash: string;
|
|
85
|
+
/** The agent the agent arm ran, by its registered name. Absent on the prompt arm. */
|
|
86
|
+
readonly agent?: string;
|
|
67
87
|
readonly score: number;
|
|
68
88
|
/** The recorded run mean, or `undefined` when this eval has never been recorded. */
|
|
69
89
|
readonly baseline: number | undefined;
|
|
@@ -77,7 +97,10 @@ export interface EvalResult {
|
|
|
77
97
|
|
|
78
98
|
export interface Eval<V extends PromptVars = PromptVars> {
|
|
79
99
|
readonly name: string;
|
|
100
|
+
/** The prompt measured: the arm's own, or the one the agent runs on. */
|
|
80
101
|
readonly prompt: Prompt<V>;
|
|
102
|
+
/** The agent arm's agent, by its registered name; `''` until registration names it. */
|
|
103
|
+
readonly agent?: string;
|
|
81
104
|
readonly tolerance: number;
|
|
82
105
|
/** The baseline file, as declared — what `x verify` reports when it cannot be read. */
|
|
83
106
|
readonly baseline: string;
|
|
@@ -97,22 +120,36 @@ export interface EvalFact {
|
|
|
97
120
|
readonly name: string;
|
|
98
121
|
readonly prompt: string;
|
|
99
122
|
readonly promptId: string;
|
|
123
|
+
/** The agent arm's agent; absent on the prompt arm. */
|
|
124
|
+
readonly agent?: string;
|
|
100
125
|
readonly tolerance: number;
|
|
101
126
|
readonly baseline: string;
|
|
102
127
|
}
|
|
103
128
|
|
|
104
129
|
const registry = new Map<string, Eval>();
|
|
105
130
|
|
|
106
|
-
|
|
107
|
-
|
|
131
|
+
/** One way to declare an eval, two arms: `prompt` (one request per case) or `agent` (one run). */
|
|
132
|
+
export function defineEval<V extends PromptVars>(input: PromptEvalInput<V>): Eval<V>;
|
|
133
|
+
export function defineEval<I>(input: AgentEvalInput<I>): Eval;
|
|
134
|
+
export function defineEval(input: DefineEvalInput): Eval {
|
|
135
|
+
const prompt = 'prompt' in input ? input.prompt : agentPrompt(input.agent, input.name);
|
|
136
|
+
const evaluation: Eval = {
|
|
108
137
|
name: input.name,
|
|
109
|
-
prompt
|
|
138
|
+
prompt,
|
|
139
|
+
// A getter: an agent is named by `registerAction` at boot, after this ran at module scope.
|
|
140
|
+
...('agent' in input
|
|
141
|
+
? {
|
|
142
|
+
get agent() {
|
|
143
|
+
return input.agent.name;
|
|
144
|
+
},
|
|
145
|
+
}
|
|
146
|
+
: {}),
|
|
110
147
|
tolerance: input.tolerance,
|
|
111
148
|
baseline: input.baseline,
|
|
112
|
-
run: (gateway) => runEval(input, gateway),
|
|
113
|
-
assert: (gateway) => assertEval(input, gateway),
|
|
149
|
+
run: (gateway) => runEval(input, prompt, gateway),
|
|
150
|
+
assert: (gateway) => assertEval(input, prompt, gateway),
|
|
114
151
|
};
|
|
115
|
-
registry.set(input.name, evaluation
|
|
152
|
+
registry.set(input.name, evaluation);
|
|
116
153
|
return evaluation;
|
|
117
154
|
}
|
|
118
155
|
|
|
@@ -122,6 +159,7 @@ export function describeEvals(): readonly EvalFact[] {
|
|
|
122
159
|
name: evaluation.name,
|
|
123
160
|
prompt: evaluation.prompt.ref,
|
|
124
161
|
promptId: evaluation.prompt.id,
|
|
162
|
+
...(evaluation.agent === undefined ? {} : { agent: evaluation.agent }),
|
|
125
163
|
tolerance: evaluation.tolerance,
|
|
126
164
|
baseline: evaluation.baseline,
|
|
127
165
|
}))
|
|
@@ -141,12 +179,13 @@ export function resetEvals(): void {
|
|
|
141
179
|
registry.clear();
|
|
142
180
|
}
|
|
143
181
|
|
|
144
|
-
async function assertEval
|
|
145
|
-
input: DefineEvalInput
|
|
182
|
+
async function assertEval(
|
|
183
|
+
input: DefineEvalInput,
|
|
184
|
+
prompt: Prompt,
|
|
146
185
|
gateway: Gateway,
|
|
147
186
|
): Promise<EvalResult> {
|
|
148
187
|
const path = baselinePath(input.baseline, input.name);
|
|
149
|
-
const result = await runEval(input, gateway);
|
|
188
|
+
const result = await runEval(input, prompt, gateway);
|
|
150
189
|
|
|
151
190
|
if (recordingBaselines()) {
|
|
152
191
|
await writeBaseline(path, baselineFrom(result));
|
|
@@ -172,41 +211,25 @@ async function assertEval<V extends PromptVars>(
|
|
|
172
211
|
return result;
|
|
173
212
|
}
|
|
174
213
|
|
|
175
|
-
async function runEval
|
|
176
|
-
input: DefineEvalInput
|
|
214
|
+
async function runEval(
|
|
215
|
+
input: DefineEvalInput,
|
|
216
|
+
prompt: Prompt,
|
|
177
217
|
gateway: Gateway,
|
|
178
218
|
): Promise<EvalResult> {
|
|
179
219
|
const cases: CaseResult[] = [];
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
...(input.prompt.thinking !== undefined ? { thinking: input.prompt.thinking } : {}),
|
|
194
|
-
});
|
|
195
|
-
const perScorer: Record<string, number> = {};
|
|
196
|
-
for (const scorer of input.scorers) {
|
|
197
|
-
perScorer[scorer.name] = clampScore(
|
|
198
|
-
await scorer.score({
|
|
199
|
-
output: generated.text,
|
|
200
|
-
...(testCase.expected !== undefined ? { expected: testCase.expected } : {}),
|
|
201
|
-
}),
|
|
202
|
-
);
|
|
203
|
-
}
|
|
204
|
-
cases.push({
|
|
205
|
-
case: testCase.name,
|
|
206
|
-
output: generated.text,
|
|
207
|
-
score: mean(Object.values(perScorer)),
|
|
208
|
-
perScorer,
|
|
209
|
-
});
|
|
220
|
+
const outcomes =
|
|
221
|
+
'prompt' in input
|
|
222
|
+
? input.cases.map((testCase) => ({
|
|
223
|
+
testCase,
|
|
224
|
+
outcome: () => promptCase(input, testCase.vars, gateway),
|
|
225
|
+
}))
|
|
226
|
+
: input.cases.map((testCase) => ({
|
|
227
|
+
testCase,
|
|
228
|
+
outcome: () => runAgentCase(input, testCase, gateway),
|
|
229
|
+
}));
|
|
230
|
+
// Serial, as before: a case is a paid call (an agent case, several), and order is the report's.
|
|
231
|
+
for (const { testCase, outcome } of outcomes) {
|
|
232
|
+
cases.push(await scoreCase(input.scorers, testCase, await outcome()));
|
|
210
233
|
}
|
|
211
234
|
|
|
212
235
|
const score = mean(cases.map((c) => c.score));
|
|
@@ -223,8 +246,9 @@ async function runEval<V extends PromptVars>(
|
|
|
223
246
|
|
|
224
247
|
return {
|
|
225
248
|
name: input.name,
|
|
226
|
-
promptRef:
|
|
227
|
-
promptHash:
|
|
249
|
+
promptRef: prompt.ref,
|
|
250
|
+
promptHash: prompt.hash,
|
|
251
|
+
...('agent' in input ? { agent: input.agent.name } : {}),
|
|
228
252
|
score,
|
|
229
253
|
baseline: recorded?.score,
|
|
230
254
|
tolerance: input.tolerance,
|
|
@@ -234,6 +258,62 @@ async function runEval<V extends PromptVars>(
|
|
|
234
258
|
};
|
|
235
259
|
}
|
|
236
260
|
|
|
261
|
+
/** The prompt arm's one request: the rendered prompt, no tools. */
|
|
262
|
+
async function promptCase(
|
|
263
|
+
input: PromptEvalInput,
|
|
264
|
+
vars: PromptVars,
|
|
265
|
+
gateway: Gateway,
|
|
266
|
+
): Promise<CaseOutcome> {
|
|
267
|
+
const generated = await gateway.generate({
|
|
268
|
+
messages: [{ role: 'user' as const, content: input.prompt.render(vars) }],
|
|
269
|
+
// A ceiling of `NaN` is not a ceiling: it becomes the pre-flight estimate, passes every
|
|
270
|
+
// budget check and then poisons the ledger it was checked against. Refused here, per RUN,
|
|
271
|
+
// because `defineEval` takes it as a plain field and there is no earlier seam.
|
|
272
|
+
maxTokens: finiteCount('defineEval', 'maxTokens', input.maxTokens ?? 1_024, 1),
|
|
273
|
+
...(input.prompt.system !== undefined ? { system: input.prompt.system } : {}),
|
|
274
|
+
...(input.prompt.model !== undefined ? { model: input.prompt.model } : {}),
|
|
275
|
+
...(input.prompt.effort !== undefined ? { effort: input.prompt.effort } : {}),
|
|
276
|
+
// `thinking` too, and not because it is tidy: `promptHash` covers it, so the baseline is
|
|
277
|
+
// filed under a hash describing a configuration the measurement did not use. `llm()` and
|
|
278
|
+
// `agent()` have always sent both.
|
|
279
|
+
...(input.prompt.thinking !== undefined ? { thinking: input.prompt.thinking } : {}),
|
|
280
|
+
});
|
|
281
|
+
return { ok: true, output: generated.text };
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/** Every scorer over one outcome. A failed agent run scores 0 on every scorer, and says why. */
|
|
285
|
+
async function scoreCase(
|
|
286
|
+
scorers: readonly Scorer[],
|
|
287
|
+
testCase: { readonly name: string; readonly expected?: string },
|
|
288
|
+
outcome: CaseOutcome,
|
|
289
|
+
): Promise<CaseResult> {
|
|
290
|
+
const perScorer: Record<string, number> = {};
|
|
291
|
+
for (const scorer of scorers) {
|
|
292
|
+
perScorer[scorer.name] = outcome.ok
|
|
293
|
+
? clampScore(
|
|
294
|
+
await scorer.score({
|
|
295
|
+
output: outcome.output,
|
|
296
|
+
...(testCase.expected !== undefined ? { expected: testCase.expected } : {}),
|
|
297
|
+
...(outcome.run === undefined ? {} : { run: outcome.run }),
|
|
298
|
+
}),
|
|
299
|
+
)
|
|
300
|
+
: 0;
|
|
301
|
+
}
|
|
302
|
+
return {
|
|
303
|
+
case: testCase.name,
|
|
304
|
+
output: outcome.ok ? outcome.output : '',
|
|
305
|
+
score: mean(Object.values(perScorer)),
|
|
306
|
+
perScorer,
|
|
307
|
+
...(outcome.ok && outcome.run !== undefined ? { run: reported(outcome.run) } : {}),
|
|
308
|
+
...(outcome.ok ? {} : { failed: outcome.code }),
|
|
309
|
+
};
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
const reported = (run: EvalRun): NonNullable<CaseResult['run']> => ({
|
|
313
|
+
turns: run.turns,
|
|
314
|
+
toolCalls: run.toolCalls.map((call) => call.name),
|
|
315
|
+
});
|
|
316
|
+
|
|
237
317
|
const caseScores = (cases: readonly CaseResult[]): Record<string, number> =>
|
|
238
318
|
Object.fromEntries(cases.map((c) => [c.case, c.score]));
|
|
239
319
|
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
// Single responsibility: which provider the gateway asks, how often, and when it stops asking one
|
|
2
|
+
// that is down. The retry loop across providers, the backoff mapping, the retryable test, and one
|
|
3
|
+
// core `breaker()` per provider: a provider down for an hour cost `attempts` calls plus backoff on
|
|
4
|
+
// EVERY call; behind its breaker it costs `attempts × failures` per cooldown, then none.
|
|
5
|
+
|
|
6
|
+
import type { Breaker, BreakerTicket, Random } from '@ultimat3/core';
|
|
7
|
+
import {
|
|
8
|
+
backoffDelay,
|
|
9
|
+
breaker,
|
|
10
|
+
isRetryableStatus,
|
|
11
|
+
isUltimateError,
|
|
12
|
+
renderThrowable,
|
|
13
|
+
settleWith,
|
|
14
|
+
stringField,
|
|
15
|
+
tryUseContext,
|
|
16
|
+
} from '@ultimat3/core';
|
|
17
|
+
import { AiProviderUnavailableError } from './errors';
|
|
18
|
+
import type { RetryPolicy } from './gateway';
|
|
19
|
+
import type { ModelId } from './models';
|
|
20
|
+
import type { GenerateResult, Provider } from './provider';
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* `providerGateway({ breaker })`: the breaker each provider sits behind, by a function of the
|
|
24
|
+
* provider (one spelling: build it with core's `breaker()`); `'off'` declines. Absent, one per
|
|
25
|
+
* provider name at `DEFAULT_GATEWAY_BREAKER`.
|
|
26
|
+
*/
|
|
27
|
+
export type GatewayBreakerOption = ((provider: Provider) => Breaker) | 'off';
|
|
28
|
+
|
|
29
|
+
/** Five consecutive exhausted passes open a provider for thirty seconds. */
|
|
30
|
+
export const DEFAULT_GATEWAY_BREAKER = Object.freeze({ failures: 5, cooldownMs: 30_000 });
|
|
31
|
+
|
|
32
|
+
/** The one code `attempt` may collect into. Every other coded refusal is the caller's answer. */
|
|
33
|
+
const PROVIDER_UNAVAILABLE = 'X_AI_PROVIDER_UNAVAILABLE';
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Full jitter: a uniform pick from [0, exponential], capped BEFORE the roll — core's
|
|
37
|
+
* `backoffDelay` with the gateway's own field names mapped onto it, and nothing else.
|
|
38
|
+
* `gateway-backoff.test.ts` pins the refusal of a non-finite bound and the clamped negative base.
|
|
39
|
+
*/
|
|
40
|
+
export function backoffMs(policy: RetryPolicy, attempt: number, random?: Random): number {
|
|
41
|
+
return backoffDelay({
|
|
42
|
+
attempt,
|
|
43
|
+
base: policy.baseDelayMs,
|
|
44
|
+
max: policy.maxDelayMs,
|
|
45
|
+
curve: 'exponential',
|
|
46
|
+
jitter: 'full',
|
|
47
|
+
random,
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* A transport that never produced a response. Node spells these `ETIMEDOUT`/`ECONNRESET`; Bun's
|
|
53
|
+
* `fetch` spells a refused, dropped or unopenable connection `ConnectionRefused`,
|
|
54
|
+
* `ConnectionClosed` and `FailedToOpenSocket`.
|
|
55
|
+
*/
|
|
56
|
+
const RETRYABLE_TRANSPORT_CODES: ReadonlySet<string> = new Set([
|
|
57
|
+
'ETIMEDOUT',
|
|
58
|
+
'ECONNRESET',
|
|
59
|
+
'ConnectionRefused',
|
|
60
|
+
'ConnectionClosed',
|
|
61
|
+
'FailedToOpenSocket',
|
|
62
|
+
]);
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Retryable = the request was well formed and the provider was momentarily unable. A 400 is
|
|
66
|
+
* never retried: the same body produces the same rejection and only burns the budget. The status
|
|
67
|
+
* half is core's `isRetryableStatus`; the `code` half has no equivalent in core (a socket that
|
|
68
|
+
* timed out never produced a status).
|
|
69
|
+
*/
|
|
70
|
+
export function isRetryable(error: unknown): boolean {
|
|
71
|
+
if (typeof error !== 'object' || error === null) return false;
|
|
72
|
+
// A `Provider` is the APP's object: `e.status` is a getter call and, on a `Proxy`, a trap. A
|
|
73
|
+
// value that fights being read cannot be SHOWN to be retryable, so it fails closed.
|
|
74
|
+
try {
|
|
75
|
+
const e = error as { status?: unknown; code?: unknown };
|
|
76
|
+
if (typeof e.status === 'number') return isRetryableStatus(e.status);
|
|
77
|
+
return typeof e.code === 'string' && RETRYABLE_TRANSPORT_CODES.has(e.code);
|
|
78
|
+
} catch {
|
|
79
|
+
return false;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** One breaker per provider name, made on first use. `undefined` under `'off'`. */
|
|
84
|
+
export function providerBreakers(
|
|
85
|
+
option: GatewayBreakerOption | undefined,
|
|
86
|
+
): (provider: Provider) => Breaker | undefined {
|
|
87
|
+
if (option === 'off') return () => undefined;
|
|
88
|
+
if (option !== undefined) return option;
|
|
89
|
+
const gates = new Map<string, Breaker>();
|
|
90
|
+
return (provider) => {
|
|
91
|
+
let gate = gates.get(provider.name);
|
|
92
|
+
if (gate === undefined) {
|
|
93
|
+
gate = breaker({ name: `ai.${provider.name}`, ...DEFAULT_GATEWAY_BREAKER });
|
|
94
|
+
gates.set(provider.name, gate);
|
|
95
|
+
}
|
|
96
|
+
return gate;
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** A pass that ran out: a failure about the provider, unless the caller went away meanwhile. */
|
|
101
|
+
const exhausted = (gate: Breaker | undefined, ticket: BreakerTicket | undefined): void => {
|
|
102
|
+
if (tryUseContext()?.signal.aborted === true) gate?.released(ticket);
|
|
103
|
+
else gate?.failed(ticket);
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
export interface AttemptInput {
|
|
107
|
+
readonly model: ModelId;
|
|
108
|
+
readonly candidates: readonly Provider[];
|
|
109
|
+
readonly retry: RetryPolicy;
|
|
110
|
+
readonly sleep: (ms: number) => Promise<void>;
|
|
111
|
+
readonly random: Random | undefined;
|
|
112
|
+
readonly gateOf: (provider: Provider) => Breaker | undefined;
|
|
113
|
+
readonly call: (provider: Provider) => Promise<GenerateResult>;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Try every provider that serves the model, retrying each on a retryable failure with full-jitter
|
|
118
|
+
* backoff, skipping one whose breaker is open — no call, no sleep. Fallback is across PROVIDERS
|
|
119
|
+
* serving one model, never across models: a silent model swap changes what answered and what it
|
|
120
|
+
* cost. The provider that answered is stamped onto the result.
|
|
121
|
+
*/
|
|
122
|
+
export async function attemptProviders(input: AttemptInput): Promise<GenerateResult> {
|
|
123
|
+
const { model, retry } = input;
|
|
124
|
+
const failures: string[] = [];
|
|
125
|
+
if (input.candidates.length === 0) {
|
|
126
|
+
throw new AiProviderUnavailableError({
|
|
127
|
+
model,
|
|
128
|
+
attempts: [`no provider serves ${model}`],
|
|
129
|
+
unserved: true,
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
for (const provider of input.candidates) {
|
|
133
|
+
const gate = input.gateOf(provider);
|
|
134
|
+
const ticket = gate?.admit();
|
|
135
|
+
if (gate !== undefined && ticket === undefined) {
|
|
136
|
+
failures.push(`${provider.name}: breaker open`);
|
|
137
|
+
continue;
|
|
138
|
+
}
|
|
139
|
+
for (let attempt = 1; attempt <= retry.attempts; attempt += 1) {
|
|
140
|
+
try {
|
|
141
|
+
const answer = { ...(await input.call(provider)), provider: provider.name };
|
|
142
|
+
gate?.succeeded(ticket);
|
|
143
|
+
return answer;
|
|
144
|
+
} catch (error) {
|
|
145
|
+
// A coded refusal that is NOT `X_AI_PROVIDER_UNAVAILABLE` reaches the caller verbatim: it
|
|
146
|
+
// was raised before the socket opened, and the same rejection waits on every provider. The
|
|
147
|
+
// provider did not fail, so its breaker hears an answer. `stringField`, never `error.code`:
|
|
148
|
+
// the value came from an app's `Provider`, and a property read on it can trap.
|
|
149
|
+
if (isUltimateError(error) && stringField(error, 'code') !== PROVIDER_UNAVAILABLE) {
|
|
150
|
+
// The breaker's own verdict: a caller's abort (`X_ABORTED`, `X_DRAINING`) gives the probe
|
|
151
|
+
// back and closes nothing; a local refusal is the provider answering.
|
|
152
|
+
if (gate !== undefined) settleWith(gate, gate.judge(error), ticket);
|
|
153
|
+
throw error;
|
|
154
|
+
}
|
|
155
|
+
// `renderThrowable`: this line becomes a `cause`, and a renderer that throws would replace
|
|
156
|
+
// the coded refusal with a `TypeError`. It bounds a provider's 1 MB body too.
|
|
157
|
+
failures.push(`${provider.name}#${attempt}: ${renderThrowable(error)}`);
|
|
158
|
+
if (!isRetryable(error)) {
|
|
159
|
+
// A 400 is the provider answering: it is up, and a 400 never opens its breaker.
|
|
160
|
+
gate?.succeeded(ticket);
|
|
161
|
+
break;
|
|
162
|
+
}
|
|
163
|
+
if (attempt === retry.attempts) {
|
|
164
|
+
exhausted(gate, ticket);
|
|
165
|
+
break;
|
|
166
|
+
}
|
|
167
|
+
await input.sleep(backoffMs(retry, attempt, input.random));
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
throw new AiProviderUnavailableError({ model, attempts: failures });
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* The provider a STREAM goes to: the first serving the model whose breaker admits it, with its
|
|
176
|
+
* ticket. A stream never retries (tokens already handed over cannot be replayed).
|
|
177
|
+
*/
|
|
178
|
+
export function admittedProvider(
|
|
179
|
+
model: ModelId,
|
|
180
|
+
providers: readonly Provider[],
|
|
181
|
+
gateOf: (provider: Provider) => Breaker | undefined,
|
|
182
|
+
): { readonly provider: Provider; readonly ticket: BreakerTicket | undefined } {
|
|
183
|
+
const candidates = providers.filter((p) => p.models.includes(model));
|
|
184
|
+
if (candidates.length === 0) {
|
|
185
|
+
throw new AiProviderUnavailableError({
|
|
186
|
+
model,
|
|
187
|
+
attempts: providers.map((p) => `${p.name}: does not serve ${model}`),
|
|
188
|
+
unserved: true,
|
|
189
|
+
});
|
|
190
|
+
}
|
|
191
|
+
for (const provider of candidates) {
|
|
192
|
+
const gate = gateOf(provider);
|
|
193
|
+
if (gate === undefined) return { provider, ticket: undefined };
|
|
194
|
+
const ticket = gate.admit();
|
|
195
|
+
if (ticket !== undefined) return { provider, ticket };
|
|
196
|
+
}
|
|
197
|
+
throw new AiProviderUnavailableError({
|
|
198
|
+
model,
|
|
199
|
+
attempts: candidates.map((p) => `${p.name}: breaker open`),
|
|
200
|
+
});
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/** A stream's first chunk or first throw is the provider's verdict; an abandoned one says nothing. */
|
|
204
|
+
export function streamVerdict(
|
|
205
|
+
gate: Breaker | undefined,
|
|
206
|
+
ticket: BreakerTicket | undefined,
|
|
207
|
+
error: unknown,
|
|
208
|
+
): void {
|
|
209
|
+
if (gate === undefined) return;
|
|
210
|
+
if (error === undefined) gate.succeeded(ticket);
|
|
211
|
+
else if (tryUseContext()?.signal.aborted === true) gate.released(ticket);
|
|
212
|
+
else if (isRetryable(error) || stringField(error, 'code') === PROVIDER_UNAVAILABLE) {
|
|
213
|
+
gate.failed(ticket);
|
|
214
|
+
} else settleWith(gate, gate.judge(error), ticket);
|
|
215
|
+
}
|