@namzu/sdk 16.0.0 → 18.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 (73) hide show
  1. package/CHANGELOG.md +103 -0
  2. package/dist/bridge/a2a/mapper.d.ts.map +1 -1
  3. package/dist/bridge/a2a/mapper.js +13 -0
  4. package/dist/bridge/a2a/mapper.js.map +1 -1
  5. package/dist/bridge/sse/mapper.d.ts.map +1 -1
  6. package/dist/bridge/sse/mapper.js +16 -0
  7. package/dist/bridge/sse/mapper.js.map +1 -1
  8. package/dist/contracts/api.d.ts +8 -1
  9. package/dist/contracts/api.d.ts.map +1 -1
  10. package/dist/manager/run/persistence.d.ts +11 -0
  11. package/dist/manager/run/persistence.d.ts.map +1 -1
  12. package/dist/manager/run/persistence.js +13 -0
  13. package/dist/manager/run/persistence.js.map +1 -1
  14. package/dist/provider/fallback.d.ts +159 -0
  15. package/dist/provider/fallback.d.ts.map +1 -0
  16. package/dist/provider/fallback.js +285 -0
  17. package/dist/provider/fallback.js.map +1 -0
  18. package/dist/provider/index.d.ts +2 -0
  19. package/dist/provider/index.d.ts.map +1 -1
  20. package/dist/provider/index.js +1 -0
  21. package/dist/provider/index.js.map +1 -1
  22. package/dist/provider/retry.d.ts +9 -0
  23. package/dist/provider/retry.d.ts.map +1 -1
  24. package/dist/provider/retry.js +40 -3
  25. package/dist/provider/retry.js.map +1 -1
  26. package/dist/public-runtime.d.ts +2 -2
  27. package/dist/public-runtime.d.ts.map +1 -1
  28. package/dist/public-runtime.js +1 -1
  29. package/dist/public-runtime.js.map +1 -1
  30. package/dist/run/reporter.d.ts.map +1 -1
  31. package/dist/run/reporter.js +16 -0
  32. package/dist/run/reporter.js.map +1 -1
  33. package/dist/runtime/query/index.d.ts +17 -0
  34. package/dist/runtime/query/index.d.ts.map +1 -1
  35. package/dist/runtime/query/index.js +76 -3
  36. package/dist/runtime/query/index.js.map +1 -1
  37. package/dist/runtime/query/iteration/index.d.ts.map +1 -1
  38. package/dist/runtime/query/iteration/index.js +40 -1
  39. package/dist/runtime/query/iteration/index.js.map +1 -1
  40. package/dist/runtime/query/iteration/phases/context.d.ts +17 -0
  41. package/dist/runtime/query/iteration/phases/context.d.ts.map +1 -1
  42. package/dist/runtime/query/iteration/phases/context.js.map +1 -1
  43. package/dist/runtime/query/iteration/stream-turn.d.ts.map +1 -1
  44. package/dist/runtime/query/iteration/stream-turn.js +25 -0
  45. package/dist/runtime/query/iteration/stream-turn.js.map +1 -1
  46. package/dist/types/provider/stream.d.ts +35 -0
  47. package/dist/types/provider/stream.d.ts.map +1 -1
  48. package/dist/types/run/entity.d.ts +38 -0
  49. package/dist/types/run/entity.d.ts.map +1 -1
  50. package/dist/types/run/events.d.ts +29 -0
  51. package/dist/types/run/events.d.ts.map +1 -1
  52. package/dist/types/run/events.js.map +1 -1
  53. package/dist/types/run/step.d.ts +53 -0
  54. package/dist/types/run/step.d.ts.map +1 -1
  55. package/dist/types/run/step.js.map +1 -1
  56. package/package.json +1 -1
  57. package/src/bridge/a2a/mapper.ts +16 -0
  58. package/src/bridge/sse/mapper.ts +17 -0
  59. package/src/contracts/api.ts +7 -0
  60. package/src/manager/run/persistence.ts +14 -0
  61. package/src/provider/fallback.ts +388 -0
  62. package/src/provider/index.ts +6 -0
  63. package/src/provider/retry.ts +51 -4
  64. package/src/public-runtime.ts +7 -1
  65. package/src/run/reporter.ts +20 -0
  66. package/src/runtime/query/index.ts +104 -3
  67. package/src/runtime/query/iteration/index.ts +43 -1
  68. package/src/runtime/query/iteration/phases/context.ts +17 -0
  69. package/src/runtime/query/iteration/stream-turn.ts +26 -0
  70. package/src/types/provider/stream.ts +37 -0
  71. package/src/types/run/entity.ts +38 -0
  72. package/src/types/run/events.ts +29 -0
  73. package/src/types/run/step.ts +54 -0
@@ -0,0 +1,388 @@
1
+ /**
2
+ * Advance through a declared provider chain when a member cannot serve.
3
+ *
4
+ * The sibling of `withProviderRetry`, one level out: retry asks "will the SAME
5
+ * member succeed if I ask again?", this asks "will ANOTHER member succeed if I
6
+ * ask it instead?". They compose in exactly one order —
7
+ * `fallback(retry(m0), retry(m1), …)` — and that order is the policy, not an
8
+ * implementation detail:
9
+ *
10
+ * - the primary is the operator's choice and gets its retries first, so a
11
+ * throttle or a 5xx only reaches this decorator once the inner budget is
12
+ * spent;
13
+ * - a failure the inner loop refuses to retry (`auth`, `not_found`) arrives
14
+ * here immediately, which is what "fall over at once on a bad credential"
15
+ * means — retrying a wrong key just spends the turn;
16
+ * - a server-directed `Retry-After` is honoured by the inner loop before any
17
+ * error escapes, so a transient wait is a wait and not a swap.
18
+ *
19
+ * None of those three behaviours is implemented here. They fall out of the
20
+ * nesting, which is why `query()` builds the composition rather than letting a
21
+ * host assemble it in whichever order it happens to pick.
22
+ */
23
+
24
+ import { classifyProviderError, isAbortError } from '../types/provider/errors.js'
25
+ import type { ChatCompletionParams, LLMProvider, StreamChunk } from '../types/provider/index.js'
26
+ import type { Logger } from '../utils/logger.js'
27
+
28
+ /**
29
+ * One member of the chain: a constructed provider, and the model to ask it for.
30
+ *
31
+ * `model` overrides {@link ChatCompletionParams.model} for this member's
32
+ * requests and nothing else. Every shipped driver reads that field
33
+ * (`params.model`, or `params.model || <its configured default>`), so a member
34
+ * needs no reconstruction to be asked for a different model — which is what
35
+ * keeps this decorator ignorant of credentials, base URLs and registries. A
36
+ * chain is an ordered list of (provider, model); building one is the host's
37
+ * job, walking it is this file's.
38
+ *
39
+ * Absent means "whatever the request already asked for", which is the right
40
+ * default for a member declared without a model: the registry default was
41
+ * resolved into the request before it got here.
42
+ */
43
+ export interface ProviderChainMember {
44
+ readonly provider: LLMProvider
45
+ readonly model?: string
46
+ }
47
+
48
+ /**
49
+ * The member serving from now on.
50
+ *
51
+ * `index` is a position in the chain the host declared, so a reader can name
52
+ * the member without holding the chain: "member 2 of 4" is the sentence an
53
+ * operator writes in an incident note.
54
+ */
55
+ export interface ServingMember {
56
+ readonly index: number
57
+ readonly providerId: string
58
+ /** Absent for a member declared without one — see {@link ProviderChainMember.model}. */
59
+ readonly model?: string
60
+ }
61
+
62
+ export interface WithProviderFallbackOptions {
63
+ readonly log?: Logger
64
+ /**
65
+ * Called once per swap, with the member that serves from here on.
66
+ *
67
+ * A callback is enough to describe the WHOLE truth, not a sample of it,
68
+ * and that is a property of the cursor rather than of this option: the
69
+ * chain never rewinds, so "who is serving" is exactly "the head, plus
70
+ * every swap so far". A listener that starts at member 0 and applies each
71
+ * call is never behind.
72
+ *
73
+ * It exists beside the in-band `fallback` chunk rather than instead of it
74
+ * because the two have different observers and neither covers the other's
75
+ * case. The chunk reaches whoever is iterating the stream, at the moment
76
+ * of the swap — that is the operator. This reaches a party that has to
77
+ * know AFTER the request is over and may never have iterated the stream at
78
+ * all — that is the run record. Two things follow that the chunk alone
79
+ * cannot give it:
80
+ *
81
+ * - the cursor outlives the request, so a swap on the turn at step 3
82
+ * still describes steps 4..N, which emit no further chunk;
83
+ * - a side call that aggregates the stream through `collect()` — the
84
+ * compaction verifier and the forced-final summary both do — drops the
85
+ * `fallback` chunk on the floor, so a swap inside one is invisible to
86
+ * every chunk consumer. (The advisory executor calls its OWN advisor's
87
+ * provider, not the run's, so it is not one of these.)
88
+ *
89
+ * ## Fired when the replacement is ASKED, not when the cursor moves
90
+ *
91
+ * The two are not the same instant and the difference is observable. The
92
+ * cursor moves inside the catch; the notice chunk is then yielded, and the
93
+ * replacement request is only issued when the consumer comes back for
94
+ * another chunk. A consumer that stops there — a Stop, a `break`, a host
95
+ * that abandons the iterator — leaves a chain that selected a member and
96
+ * never asked it.
97
+ *
98
+ * Announcing at cursor-move would report that member as serving, and a
99
+ * ledger saying a provider served a turn it was never sent is the exact
100
+ * defect this callback exists to end, reintroduced one layer down. So the
101
+ * announcement sits at the top of the loop, immediately before the
102
+ * replacement's `chatStream` — the earliest moment at which the member is
103
+ * actually being asked.
104
+ *
105
+ * ## One stream at a time
106
+ *
107
+ * `cursor` is shared by every concurrent `chatStream` on this wrapper, so
108
+ * two overlapping calls can advance it under one another: one call's
109
+ * failure moves the cursor while the other is still being served by the
110
+ * head, and a listener would hear about a member that answered nothing for
111
+ * that call. Nothing here serializes or refuses concurrency — the
112
+ * property held before this option existed and is not introduced by it.
113
+ * `query()` issues its main turn and its side calls in sequence, which is
114
+ * what makes the reading exact there.
115
+ */
116
+ readonly onSwap?: (to: ServingMember) => void
117
+ }
118
+
119
+ /**
120
+ * Failures that are a property of the REQUEST rather than of the member serving
121
+ * it.
122
+ *
123
+ * This is the whole decision, stated once. A request fault reproduces
124
+ * identically on the next member, so falling over would spend a second
125
+ * provider's money to buy the same error. Everything else — a throttle, an
126
+ * outage, a rejected credential, a model this provider does not have, a stream
127
+ * that came back malformed — is a fact about the member, and the next member is
128
+ * worth asking.
129
+ *
130
+ * The three entries, and what each of them being here means:
131
+ *
132
+ * - `context_length_exceeded` — the run's own remedy is compaction, which sheds
133
+ * history and asks again. Falling over instead would carry the same oversized
134
+ * prompt to a provider that has not been given the chance to fit it. Note the
135
+ * limit of that reasoning: a chain MAY pair a small window with a larger one,
136
+ * and then a fallback genuinely could have succeeded. Compaction is still
137
+ * preferred because it is cheaper and it keeps the operator on the provider
138
+ * they chose.
139
+ * - `invalid_request` — namzu built a request the provider rejected. The
140
+ * counter-case is real and is recorded here rather than hidden: drivers
141
+ * translate the same tool schema differently onto the wire, so a 400 about a
142
+ * JSON Schema dialect (see `provider/errors.ts`) is provider-specific and
143
+ * another member might accept it. Abort still wins, because the common case
144
+ * is a request that is wrong everywhere, and spending the entire chain to
145
+ * rediscover one defect is the worse failure.
146
+ * - `content_filter` — a refusal. No shipped driver reaches this through a
147
+ * THROW today: the drivers that surface a filtered completion do so as
148
+ * `finishReason: 'content_filter'`, which is a finished stream, not an error.
149
+ * The input that makes this entry fire is a driver that throws instead — the
150
+ * classifier maps a `content_policy_violation` structural code onto it, and
151
+ * `ProviderError` is a public type a third-party driver constructs directly.
152
+ * Named rather than omitted for that reason; see
153
+ * `docs/conventions/a-check-that-cannot-fail.md` for why an unnameable one
154
+ * would have been left out.
155
+ */
156
+ const REQUEST_FAULT_CODES: ReadonlySet<string> = new Set([
157
+ 'context_length_exceeded',
158
+ 'invalid_request',
159
+ 'content_filter',
160
+ ])
161
+
162
+ /**
163
+ * Does this failure justify asking the next member?
164
+ *
165
+ * The classification is taken from `classifyProviderError` and not re-derived.
166
+ * A second classifier that disagreed with the first would be two answers to one
167
+ * question, and the retry decorator already stands on that same answer.
168
+ *
169
+ * `status === 404` is read ALONGSIDE the code, and it is not a second opinion —
170
+ * it is a second field of the one classification. A 404 reaches `not_found`
171
+ * only on the unclassified path (`codeFromStatus`). A driver that classified
172
+ * its own 404 into a `ProviderRequestError` gets `kind: 'bad_request'` from
173
+ * `classifyProviderHttpStatus`, which `KIND_TO_CODE` maps to
174
+ * `invalid_request` — a request fault, so the run would ABORT on a model the
175
+ * next member may well have. The status survives on both paths; the code does
176
+ * not, so the status is what this reads.
177
+ */
178
+ function shouldFallOver(err: unknown, providerId: string): boolean {
179
+ const classified = classifyProviderError(err, providerId)
180
+ if (classified.status === 404) return true
181
+ return !REQUEST_FAULT_CODES.has(classified.code)
182
+ }
183
+
184
+ /**
185
+ * Has the consumer already been handed something a restart would duplicate?
186
+ *
187
+ * Deliberately NOT the retry decorator's rule, and the difference is the bug
188
+ * this function exists to prevent. Retry treats every chunk that is not
189
+ * error-only as output, which is correct INSIDE retry because each attempt
190
+ * starts with the flag cleared. Reused one level out it is wrong in the worst
191
+ * possible place: the inner decorator emits a backoff notice through this
192
+ * stream on its way to sleeping, that notice is not error-only, and a fallback
193
+ * reading it as output would refuse to advance for the rest of the turn — for
194
+ * the 429/5xx path, which is the main reason a chain is declared at all.
195
+ *
196
+ * So this asks the narrow question instead: did a chunk carry something the
197
+ * orchestrator turned into a visible event? `text_delta`, tool-input fragments
198
+ * and their per-tool boundary, reasoning blocks and citations all did.
199
+ * `usage`, `finishReason` and the two control notices did not.
200
+ */
201
+ function isOutputChunk(chunk: StreamChunk): boolean {
202
+ if (chunk.retry !== undefined || chunk.fallback !== undefined) return false
203
+ const { content, toolCalls, toolCallEnd, reasoning, citation } = chunk.delta
204
+ return Boolean(
205
+ content ||
206
+ toolCalls?.length ||
207
+ toolCallEnd ||
208
+ reasoning !== undefined ||
209
+ citation !== undefined,
210
+ )
211
+ }
212
+
213
+ /**
214
+ * Wrap an ordered chain so a member that cannot serve is replaced in place.
215
+ *
216
+ * ## The cursor's lifetime IS the scope
217
+ *
218
+ * Once this decorator advances, every later request in its life goes to the new
219
+ * member; the chain never rewinds. That is deliberate and it is how "the
220
+ * primary is restored at each new user message" is implemented — by NOT
221
+ * implementing it. `query()` builds one of these per call and a host's call is
222
+ * its turn, so the cursor cannot outlive the turn because the object cannot.
223
+ * There is no reset to forget to call, and no way for a rate limit at 14:00 to
224
+ * leave an operator on a cheaper model at 17:00.
225
+ *
226
+ * Rewinding within the turn would be the alternative, and it is worse: the
227
+ * member that just failed would be re-asked on the next iteration of the same
228
+ * turn, which is a retry wearing a chain's clothes and which the inner
229
+ * decorator already declined to do.
230
+ *
231
+ * ## Each member is tried at most once per turn, and the whole chain is walked
232
+ *
233
+ * A chain of N members yields up to N attempts, not one. An operator who
234
+ * declares four members and is served only by the second on a bad day has been
235
+ * given three decorative entries — that is the declared-but-undriven defect
236
+ * this file exists to remove, and stopping after one step would reintroduce it
237
+ * at position 2 instead of position 1. When the last member fails, its error is
238
+ * thrown untouched and ordinary error handling takes over.
239
+ *
240
+ * ## Once output is out, there is no fallback
241
+ *
242
+ * Inherited from retry, for the same reason and not a weaker one: a stream that
243
+ * has emitted bytes cannot be restarted without duplicating them, and the
244
+ * consumer has already appended them to a message it is rendering. A mid-stream
245
+ * failure after output is surfaced, never swapped. See {@link isOutputChunk} —
246
+ * the definition of "output" is where this property is actually won or lost.
247
+ *
248
+ * ## Capabilities are the head's
249
+ *
250
+ * The getters below are transparent, so a run negotiates tools, vision and
251
+ * documents ONCE against `members[0]` and keeps that answer after a swap. This
252
+ * is a real limitation and it is why the host is expected to refuse a chain
253
+ * whose members disagree before ever building one (`@namzu/cli` does, and only
254
+ * runs a mismatched chain when the operator has said so explicitly). Taking the
255
+ * intersection here instead would cost the primary a capability on every run to
256
+ * guard against a failure that happens rarely.
257
+ */
258
+ export function withProviderFallback(
259
+ members: readonly ProviderChainMember[],
260
+ options: WithProviderFallbackOptions = {},
261
+ ): LLMProvider {
262
+ const first = members[0]
263
+ if (!first) {
264
+ throw new Error('withProviderFallback needs at least one member')
265
+ }
266
+ // A one-member chain is the identity. Returning the provider itself rather
267
+ // than a wrapper that can never advance keeps the no-chain path byte-identical
268
+ // to what it was before this file existed.
269
+ //
270
+ // `onSwap` is therefore never called on this path, and that is the correct
271
+ // reading rather than a hole: a listener starts at member 0 and a one-member
272
+ // chain never leaves it. Announcing member 0 here would say "the chain
273
+ // advanced" about a chain that cannot.
274
+ if (members.length === 1) return first.provider
275
+
276
+ const log = options.log
277
+ let cursor = 0
278
+ /**
279
+ * The last position {@link WithProviderFallbackOptions.onSwap} was told
280
+ * about. Lags `cursor` for exactly as long as the consumer has the notice
281
+ * chunk and has not come back for more — which is the window in which a
282
+ * selected member has not been asked anything. See that option's doc.
283
+ */
284
+ let announced = 0
285
+
286
+ async function* chatStream(params: ChatCompletionParams): AsyncIterable<StreamChunk> {
287
+ for (;;) {
288
+ const member = members[cursor]
289
+ // Unreachable while `cursor` only ever moves to an index this loop has
290
+ // bounds-checked below; kept because the alternative on a future edit is
291
+ // calling `chatStream` on undefined, which names neither the field nor
292
+ // the fix.
293
+ if (!member) throw new Error(`provider chain has no member at position ${cursor}`)
294
+
295
+ if (announced !== cursor) {
296
+ announced = cursor
297
+ options.onSwap?.({
298
+ index: cursor,
299
+ providerId: member.provider.id,
300
+ ...(member.model !== undefined ? { model: member.model } : {}),
301
+ })
302
+ }
303
+
304
+ const request = member.model !== undefined ? { ...params, model: member.model } : params
305
+ let produced = false
306
+ try {
307
+ for await (const chunk of member.provider.chatStream(request)) {
308
+ if (isOutputChunk(chunk)) produced = true
309
+ yield chunk
310
+ }
311
+ return
312
+ } catch (err) {
313
+ // A Stop is control flow, not a provider failure. Without this the
314
+ // classifier — which has no concept of cancellation — would file an
315
+ // abort as some ordinary failure and the run would walk the entire
316
+ // chain re-issuing an already-cancelled request, one member at a
317
+ // time, instead of settling as `cancelled`. The retry decorator
318
+ // guards the same way and for the same reason.
319
+ if (isAbortError(err) || params.signal?.aborted) throw err
320
+
321
+ const next = cursor + 1
322
+ const to = members[next]
323
+ if (produced || !to || !shouldFallOver(err, member.provider.id)) {
324
+ log?.warn('Provider chain: not falling over', {
325
+ provider: member.provider.id,
326
+ position: cursor,
327
+ reason: produced
328
+ ? 'stream already produced output — cannot restart without duplicating it'
329
+ : !to
330
+ ? 'chain exhausted'
331
+ : 'the failure is a property of the request, not of the provider',
332
+ })
333
+ throw err
334
+ }
335
+
336
+ const classified = classifyProviderError(err, member.provider.id)
337
+ cursor = next
338
+ log?.warn('Provider chain: falling over', {
339
+ from: member.provider.id,
340
+ fromPosition: cursor - 1,
341
+ to: to.provider.id,
342
+ toPosition: cursor,
343
+ code: classified.code,
344
+ status: classified.status,
345
+ })
346
+
347
+ // In-band, exactly like the retry notice and for the same reason: the
348
+ // consumer is blocked inside this iterator, so the stream is the only
349
+ // channel that reaches it at the moment the swap happens rather than
350
+ // after the replacement request has already run.
351
+ yield {
352
+ id: '',
353
+ delta: {},
354
+ fallback: {
355
+ fromIndex: cursor - 1,
356
+ fromProviderId: member.provider.id,
357
+ ...(member.model !== undefined ? { fromModel: member.model } : {}),
358
+ toIndex: cursor,
359
+ toProviderId: to.provider.id,
360
+ ...(to.model !== undefined ? { toModel: to.model } : {}),
361
+ code: classified.code,
362
+ ...(classified.status !== undefined ? { status: classified.status } : {}),
363
+ reason: classified.message,
364
+ },
365
+ }
366
+ }
367
+ }
368
+ }
369
+
370
+ // Transparent to capability negotiation and identity, like the retry
371
+ // decorator. The head's declarations are what a run is configured from; see
372
+ // the note on capabilities above for the limitation that carries.
373
+ return {
374
+ get id() {
375
+ return first.provider.id
376
+ },
377
+ get name() {
378
+ return first.provider.name
379
+ },
380
+ get capabilities() {
381
+ return first.provider.capabilities
382
+ },
383
+ chatStream,
384
+ ...(first.provider.listModels ? { listModels: () => first.provider.listModels?.() } : {}),
385
+ ...(first.provider.healthCheck ? { healthCheck: () => first.provider.healthCheck?.() } : {}),
386
+ ...(first.provider.doctorCheck ? { doctorCheck: () => first.provider.doctorCheck?.() } : {}),
387
+ } as LLMProvider
388
+ }
@@ -35,3 +35,9 @@ export {
35
35
  } from '../types/provider/errors.js'
36
36
  export { DEFAULT_PROVIDER_RETRY, withProviderRetry } from './retry.js'
37
37
  export type { ProviderRetryConfig, WithProviderRetryOptions } from './retry.js'
38
+ export { withProviderFallback } from './fallback.js'
39
+ export type {
40
+ ProviderChainMember,
41
+ ServingMember,
42
+ WithProviderFallbackOptions,
43
+ } from './fallback.js'
@@ -14,6 +14,15 @@ export interface ProviderRetryConfig {
14
14
  * Cap on a server-directed `Retry-After`. A provider asking for 15
15
15
  * minutes should not silently park an interactive run for 15 minutes;
16
16
  * past this we surface the error and let the caller decide.
17
+ *
18
+ * "Surface" is the whole of it: there is no shorter retry underneath. A
19
+ * server that named a wait has said something specific, and answering it
20
+ * with a half-second backoff neither honours the wait nor tells anyone it
21
+ * was refused. The error carries `retryAfterMs`, so a host that wants to
22
+ * come back in fifteen minutes can — that decision is above this loop.
23
+ *
24
+ * Raise it to let the run sleep longer; a request under the ceiling is
25
+ * still slept exactly as instructed.
17
26
  */
18
27
  readonly maxRetryAfterMs: number
19
28
  }
@@ -153,10 +162,48 @@ export function withProviderRetry(
153
162
  }
154
163
 
155
164
  const serverDirected = classified.retryAfterMs
156
- const delay =
157
- serverDirected !== undefined && serverDirected <= config.maxRetryAfterMs
158
- ? serverDirected
159
- : backoffWithJitter(attempt, config, random)
165
+
166
+ // The server named a wait longer than the caller's ceiling, so the
167
+ // error goes to the caller — which is what `maxRetryAfterMs`
168
+ // documents and what it now does.
169
+ //
170
+ // It used to fall through to the jittered backoff instead, and that
171
+ // is degrading where the contract says refuse
172
+ // (`docs/conventions/refuse-do-not-degrade.md`). The ceiling was
173
+ // read as "how long may I sleep", so a provider asking for fifteen
174
+ // minutes was re-asked in half a second: the one instruction the
175
+ // server gave was the one thing discarded, and the retries that
176
+ // followed were sent to an endpoint that had already said it would
177
+ // not serve them. They cost the run its whole budget to rediscover
178
+ // a 429 it had been told about in advance.
179
+ //
180
+ // The caller loses nothing it had. This throws the SAME error the
181
+ // exhausted path throws, so the run settles exactly as it did
182
+ // before — only sooner, and with `retryAfterMs` intact for a host
183
+ // that wants to schedule against it. What it gains is the wait
184
+ // itself, which no backoff of ours can honour: fifteen minutes is
185
+ // not a number this loop is allowed to sleep for.
186
+ //
187
+ // With a chain declared it gains more than that. The error is a
188
+ // `rate_limit`, which is a fact about the MEMBER, so
189
+ // `withProviderFallback` moves to the next one — the run continues
190
+ // on another provider instead of spending its budget arguing with
191
+ // the first. Under the old behaviour the chain did not see the
192
+ // failure until those attempts were gone.
193
+ if (serverDirected !== undefined && serverDirected > config.maxRetryAfterMs) {
194
+ log?.warn('Provider call failed — server-directed wait exceeds the ceiling', {
195
+ provider: provider.id,
196
+ code: classified.code,
197
+ status: classified.status,
198
+ attempt: attempt + 1,
199
+ retryAfterMs: serverDirected,
200
+ maxRetryAfterMs: config.maxRetryAfterMs,
201
+ reason: 'surfacing rather than retrying — the caller decides how to wait',
202
+ })
203
+ throw isProviderRequestError(err) ? err : classified
204
+ }
205
+
206
+ const delay = serverDirected ?? backoffWithJitter(attempt, config, random)
160
207
 
161
208
  log?.warn('Provider call failed — retrying', {
162
209
  provider: provider.id,
@@ -268,9 +268,15 @@ export {
268
268
  registerMock,
269
269
  resolveProviderCapabilities,
270
270
  UnknownProviderError,
271
+ withProviderFallback,
271
272
  withProviderRetry,
272
273
  } from './provider/index.js'
273
- export type { ProviderRetryConfig, WithProviderRetryOptions } from './provider/index.js'
274
+ export type {
275
+ ProviderChainMember,
276
+ ProviderRetryConfig,
277
+ WithProviderFallbackOptions,
278
+ WithProviderRetryOptions,
279
+ } from './provider/index.js'
274
280
 
275
281
  export {
276
282
  assertIsolation,
@@ -332,6 +332,26 @@ export function createRunReporter(parentLogger?: Logger): RunReporter {
332
332
  })
333
333
  break
334
334
 
335
+ case 'provider_fallback':
336
+ // `warn` for the same reason as a retry, and one stronger: the rest
337
+ // of this run is being served by a provider the caller did not pick.
338
+ log.warn(
339
+ `Provider ${event.fromProviderId} could not serve — continuing on ${event.toProviderId}`,
340
+ {
341
+ runId: event.runId,
342
+ iteration: event.iteration,
343
+ fromIndex: event.fromIndex,
344
+ fromProviderId: event.fromProviderId,
345
+ fromModel: event.fromModel,
346
+ toIndex: event.toIndex,
347
+ toProviderId: event.toProviderId,
348
+ toModel: event.toModel,
349
+ code: event.code,
350
+ status: event.status,
351
+ },
352
+ )
353
+ break
354
+
335
355
  default: {
336
356
  const _exhaustive: never = event
337
357
  throw new Error(`Unhandled run event type: ${(_exhaustive as RunEvent).type}`)
@@ -16,6 +16,11 @@ import type { CompactionConfig } from '../../config/runtime.js'
16
16
  import { TOOL_OUTPUT_DIR_NAME } from '../../constants/tools/index.js'
17
17
  import { EmergencySaveManager } from '../../manager/run/emergency.js'
18
18
  import { resolveProviderCapabilities } from '../../provider/capabilities.js'
19
+ import {
20
+ type ProviderChainMember,
21
+ type ServingMember,
22
+ withProviderFallback,
23
+ } from '../../provider/fallback.js'
19
24
  import { type ProviderRetryConfig, withProviderRetry } from '../../provider/retry.js'
20
25
  import type { PathBuilder } from '../../session/workspace/path-builder.js'
21
26
  import {
@@ -120,6 +125,23 @@ export interface QueryParams {
120
125
  */
121
126
  retry?: Partial<ProviderRetryConfig> | false
122
127
 
128
+ /**
129
+ * Members to fall over to, in order, when {@link provider} cannot serve.
130
+ *
131
+ * Absent means what it always meant: one provider, no failover. Each member
132
+ * is tried at most once per call and the chain never rewinds, so the scope
133
+ * of a swap is this `query()` — for a host whose call is one user turn, that
134
+ * is turn scope with no reset to forget. See `withProviderFallback`.
135
+ *
136
+ * Two things this does NOT do, both deliberate. Capabilities are negotiated
137
+ * once against {@link provider}, so a member that declares less will be sent
138
+ * a request shaped for the head — refuse a disagreeing chain before you
139
+ * build one. And a fallback loses the prompt cache: the replacement provider
140
+ * has never seen this conversation, so the turn re-reads its whole context
141
+ * at full price.
142
+ */
143
+ fallbackProviders?: readonly ProviderChainMember[]
144
+
123
145
  /**
124
146
  * Install process-level crash handlers that dump this run's state to
125
147
  * `<runDir>/../emergency/<runId>.json` on SIGINT, SIGTERM or an
@@ -472,6 +494,46 @@ export interface QueryParams {
472
494
  strictCapabilities?: boolean
473
495
  }
474
496
 
497
+ /**
498
+ * Refuse to price a run whose tokens two differently-priced members may produce.
499
+ *
500
+ * `RunPersistence` holds ONE {@link ModelPricing} table and applies it to every
501
+ * accumulation regardless of which model produced the tokens. Across a swap that
502
+ * makes `costInfo.totalCost` wrong by an unbounded margin, and silently — the
503
+ * number keeps the shape of an answer. `CostInfo` cannot express the truth
504
+ * either: it carries `inputCostPer1M` / `outputCostPer1M`, and there is no
505
+ * honest value for those once a total spans two rate cards.
506
+ *
507
+ * So the total is refused rather than blended. Naming what that costs is part
508
+ * of the refusal, because the caller loses `costLimitUsd` with it: the guard
509
+ * enforces that limit from this same accumulated total, and a limit enforced
510
+ * with the wrong rate card stops a run early or late by the same unbounded
511
+ * margin. A budget that is quietly wrong is worse than a budget that is
512
+ * declined.
513
+ *
514
+ * Reachable, not decorative: a host that passes `pricing` and declares a chain
515
+ * hits it on the first call. It costs `@namzu/cli` nothing, which passes no
516
+ * pricing at all — its `/cost` already reports that the provider gave no price.
517
+ *
518
+ * The way out is per-member pricing, which needs a `CostInfo` that can sum over
519
+ * heterogeneous rates. That is a public-type change and it is not this one.
520
+ */
521
+ function assertCostIsAttributable(
522
+ chain: readonly ProviderChainMember[],
523
+ pricing: ModelPricing | undefined,
524
+ ): void {
525
+ if (pricing === undefined || chain.length < 2) return
526
+ throw new NamzuError({
527
+ code: 'invalid_config',
528
+ message:
529
+ `A provider chain of ${chain.length} members was declared together with a single pricing table. ` +
530
+ 'One table cannot price two members, so the run would report a total that is wrong by an unbounded ' +
531
+ 'margin — and `runConfig.costLimitUsd` would be enforced against that same wrong total. ' +
532
+ 'Either drop `pricing` (usage is still reported per model in the run) or declare one member.',
533
+ details: { chainLength: chain.length },
534
+ })
535
+ }
536
+
475
537
  export async function* query(params: QueryParams): AsyncGenerator<RunEvent, Run> {
476
538
  // Boot-time filesystem migration (session-hierarchy.md §13.4.1). First
477
539
  // call per process per root actually runs; subsequent calls short-circuit
@@ -489,10 +551,48 @@ export async function* query(params: QueryParams): AsyncGenerator<RunEvent, Run>
489
551
  // of its warns behind `options.log`, and this is its only production
490
552
  // call site — so without it the "failed, retrying" and "failed, giving
491
553
  // up" lines were dead code and a backoff left no trace anywhere.
492
- const resilientProvider =
554
+ //
555
+ // With a chain declared, the same sentence holds one level out: retry is
556
+ // applied per MEMBER and the fallback decorator wraps the result, so the
557
+ // composition is `fallback(retry(m0), retry(m1), …)`. That order is not a
558
+ // preference. Assembled the other way round — which is what a host gets if
559
+ // it wraps its own chain and hands the result in, because this function
560
+ // would then wrap THAT in retry — an exhausted chain gets restarted from
561
+ // the head by the outer loop and a throttle on the last member is counted
562
+ // by two budgets. Building it here is what makes the order unspellable
563
+ // wrong.
564
+ const chain: readonly ProviderChainMember[] = [
565
+ { provider: params.provider },
566
+ ...(params.fallbackProviders ?? []),
567
+ ]
568
+ assertCostIsAttributable(chain, params.pricing)
569
+ const withRetry = (provider: LLMProvider): LLMProvider =>
493
570
  params.retry === false
494
- ? params.provider
495
- : withProviderRetry(params.provider, { config: params.retry, log: getRootLogger() })
571
+ ? provider
572
+ : withProviderRetry(provider, { config: params.retry, log: getRootLogger() })
573
+ // Who is serving right now, for the run RECORD rather than for the request.
574
+ //
575
+ // It starts at the head and moves only when the chain does, which is the
576
+ // whole of the truth because the cursor never rewinds. The run cannot read
577
+ // this off `resilientProvider`: that wrapper reports the head's `id` on
578
+ // purpose, so asking it produces the declaration back — the defect this
579
+ // record exists to fix.
580
+ const serving: { current: ServingMember } = {
581
+ current: { index: 0, providerId: params.provider.id },
582
+ }
583
+ const resilientProvider = withProviderFallback(
584
+ chain.map((member) => ({ ...member, provider: withRetry(member.provider) })),
585
+ {
586
+ log: getRootLogger(),
587
+ onSwap: (to) => {
588
+ serving.current = to
589
+ // `ctx` is declared below and is initialized before anything can
590
+ // call the provider: this fires from inside a `chatStream`, and
591
+ // the first one is issued by the loop that `ctx` is built for.
592
+ ctx.runMgr.setServingProvider(to.providerId)
593
+ },
594
+ },
595
+ )
496
596
 
497
597
  const ctx = RunContextFactory.build({
498
598
  agentId: params.agentId,
@@ -821,6 +921,7 @@ export async function* query(params: QueryParams): AsyncGenerator<RunEvent, Run>
821
921
 
822
922
  const iterationOrchestrator = new IterationOrchestrator({
823
923
  provider: resilientProvider,
924
+ servingMember: () => serving.current,
824
925
  runConfig: params.runConfig,
825
926
  ...(params.stopWhen ? { stopWhen: params.stopWhen } : {}),
826
927
  ...(params.prepareStep ? { prepareStep: params.prepareStep } : {}),