@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.
Files changed (47) hide show
  1. package/CLAUDE.md +63 -90
  2. package/README.md +125 -8
  3. package/package.json +23 -11
  4. package/src/agent-checkpoint.ts +44 -0
  5. package/src/agent-compact-def.ts +11 -0
  6. package/src/agent-compact-measure.ts +38 -0
  7. package/src/agent-compact-request.ts +100 -0
  8. package/src/agent-compact-text.ts +95 -0
  9. package/src/agent-compact.ts +75 -50
  10. package/src/agent-continue.ts +15 -3
  11. package/src/agent-def.ts +9 -0
  12. package/src/agent-errors.ts +12 -2
  13. package/src/agent-facts.ts +8 -0
  14. package/src/agent-send.ts +82 -0
  15. package/src/agent-stream.ts +76 -0
  16. package/src/agent.ts +71 -21
  17. package/src/answer-errors.ts +1 -1
  18. package/src/anthropic-provider.ts +291 -0
  19. package/src/content-errors.ts +1 -1
  20. package/src/context-overflow.ts +86 -0
  21. package/src/error-codes.ts +95 -0
  22. package/src/errors-retry.ts +8 -4
  23. package/src/errors.ts +6 -87
  24. package/src/eval-agent.ts +100 -0
  25. package/src/eval-errors.ts +31 -5
  26. package/src/evals.ts +125 -45
  27. package/src/gateway-providers.ts +215 -0
  28. package/src/gateway.ts +63 -153
  29. package/src/hive-errors.ts +1 -1
  30. package/src/in-process-tool.ts +20 -11
  31. package/src/index.ts +31 -7
  32. package/src/llm.ts +39 -8
  33. package/src/model-resolve.ts +7 -1
  34. package/src/openai-body.ts +12 -5
  35. package/src/openai-provider.ts +28 -8
  36. package/src/openai-wire.ts +11 -7
  37. package/src/prompt-cache-rows.ts +82 -0
  38. package/src/prompt-cache.ts +24 -2
  39. package/src/provider.ts +15 -274
  40. package/src/remote-embedder.ts +38 -11
  41. package/src/run-meter.ts +40 -2
  42. package/src/runtime.ts +38 -3
  43. package/src/scorers.ts +35 -1
  44. package/src/scripted-provider.ts +146 -0
  45. package/src/tools-gate.ts +46 -0
  46. package/src/tools.ts +101 -27
  47. 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
- export interface DefineEvalInput<V extends PromptVars = PromptVars> {
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
- export function defineEval<V extends PromptVars>(input: DefineEvalInput<V>): Eval<V> {
107
- const evaluation: Eval<V> = {
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: input.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 as Eval);
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<V extends PromptVars>(
145
- input: DefineEvalInput<V>,
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<V extends PromptVars>(
176
- input: DefineEvalInput<V>,
214
+ async function runEval(
215
+ input: DefineEvalInput,
216
+ prompt: Prompt,
177
217
  gateway: Gateway,
178
218
  ): Promise<EvalResult> {
179
219
  const cases: CaseResult[] = [];
180
- for (const testCase of input.cases) {
181
- const generated = await gateway.generate({
182
- messages: [{ role: 'user' as const, content: input.prompt.render(testCase.vars) }],
183
- // A ceiling of `NaN` is not a ceiling: it becomes the pre-flight estimate, passes every
184
- // budget check and then poisons the ledger it was checked against. Refused here, per RUN,
185
- // because `defineEval` takes it as a plain field and there is no earlier seam.
186
- maxTokens: finiteCount('defineEval', 'maxTokens', input.maxTokens ?? 1_024, 1),
187
- ...(input.prompt.system !== undefined ? { system: input.prompt.system } : {}),
188
- ...(input.prompt.model !== undefined ? { model: input.prompt.model } : {}),
189
- ...(input.prompt.effort !== undefined ? { effort: input.prompt.effort } : {}),
190
- // `thinking` too, and not because it is tidy: `promptHash` covers it, so the baseline is
191
- // filed under a hash describing a configuration the measurement did not use. `llm()` and
192
- // `agent()` have always sent both.
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: input.prompt.ref,
227
- promptHash: input.prompt.hash,
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
+ }