@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.
- package/CHANGELOG.md +103 -0
- package/dist/bridge/a2a/mapper.d.ts.map +1 -1
- package/dist/bridge/a2a/mapper.js +13 -0
- package/dist/bridge/a2a/mapper.js.map +1 -1
- package/dist/bridge/sse/mapper.d.ts.map +1 -1
- package/dist/bridge/sse/mapper.js +16 -0
- package/dist/bridge/sse/mapper.js.map +1 -1
- package/dist/contracts/api.d.ts +8 -1
- package/dist/contracts/api.d.ts.map +1 -1
- package/dist/manager/run/persistence.d.ts +11 -0
- package/dist/manager/run/persistence.d.ts.map +1 -1
- package/dist/manager/run/persistence.js +13 -0
- package/dist/manager/run/persistence.js.map +1 -1
- package/dist/provider/fallback.d.ts +159 -0
- package/dist/provider/fallback.d.ts.map +1 -0
- package/dist/provider/fallback.js +285 -0
- package/dist/provider/fallback.js.map +1 -0
- package/dist/provider/index.d.ts +2 -0
- package/dist/provider/index.d.ts.map +1 -1
- package/dist/provider/index.js +1 -0
- package/dist/provider/index.js.map +1 -1
- package/dist/provider/retry.d.ts +9 -0
- package/dist/provider/retry.d.ts.map +1 -1
- package/dist/provider/retry.js +40 -3
- package/dist/provider/retry.js.map +1 -1
- package/dist/public-runtime.d.ts +2 -2
- package/dist/public-runtime.d.ts.map +1 -1
- package/dist/public-runtime.js +1 -1
- package/dist/public-runtime.js.map +1 -1
- package/dist/run/reporter.d.ts.map +1 -1
- package/dist/run/reporter.js +16 -0
- package/dist/run/reporter.js.map +1 -1
- package/dist/runtime/query/index.d.ts +17 -0
- package/dist/runtime/query/index.d.ts.map +1 -1
- package/dist/runtime/query/index.js +76 -3
- package/dist/runtime/query/index.js.map +1 -1
- package/dist/runtime/query/iteration/index.d.ts.map +1 -1
- package/dist/runtime/query/iteration/index.js +40 -1
- package/dist/runtime/query/iteration/index.js.map +1 -1
- package/dist/runtime/query/iteration/phases/context.d.ts +17 -0
- package/dist/runtime/query/iteration/phases/context.d.ts.map +1 -1
- package/dist/runtime/query/iteration/phases/context.js.map +1 -1
- package/dist/runtime/query/iteration/stream-turn.d.ts.map +1 -1
- package/dist/runtime/query/iteration/stream-turn.js +25 -0
- package/dist/runtime/query/iteration/stream-turn.js.map +1 -1
- package/dist/types/provider/stream.d.ts +35 -0
- package/dist/types/provider/stream.d.ts.map +1 -1
- package/dist/types/run/entity.d.ts +38 -0
- package/dist/types/run/entity.d.ts.map +1 -1
- package/dist/types/run/events.d.ts +29 -0
- package/dist/types/run/events.d.ts.map +1 -1
- package/dist/types/run/events.js.map +1 -1
- package/dist/types/run/step.d.ts +53 -0
- package/dist/types/run/step.d.ts.map +1 -1
- package/dist/types/run/step.js.map +1 -1
- package/package.json +1 -1
- package/src/bridge/a2a/mapper.ts +16 -0
- package/src/bridge/sse/mapper.ts +17 -0
- package/src/contracts/api.ts +7 -0
- package/src/manager/run/persistence.ts +14 -0
- package/src/provider/fallback.ts +388 -0
- package/src/provider/index.ts +6 -0
- package/src/provider/retry.ts +51 -4
- package/src/public-runtime.ts +7 -1
- package/src/run/reporter.ts +20 -0
- package/src/runtime/query/index.ts +104 -3
- package/src/runtime/query/iteration/index.ts +43 -1
- package/src/runtime/query/iteration/phases/context.ts +17 -0
- package/src/runtime/query/iteration/stream-turn.ts +26 -0
- package/src/types/provider/stream.ts +37 -0
- package/src/types/run/entity.ts +38 -0
- package/src/types/run/events.ts +29 -0
- 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
|
+
}
|
package/src/provider/index.ts
CHANGED
|
@@ -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'
|
package/src/provider/retry.ts
CHANGED
|
@@ -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
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
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,
|
package/src/public-runtime.ts
CHANGED
|
@@ -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 {
|
|
274
|
+
export type {
|
|
275
|
+
ProviderChainMember,
|
|
276
|
+
ProviderRetryConfig,
|
|
277
|
+
WithProviderFallbackOptions,
|
|
278
|
+
WithProviderRetryOptions,
|
|
279
|
+
} from './provider/index.js'
|
|
274
280
|
|
|
275
281
|
export {
|
|
276
282
|
assertIsolation,
|
package/src/run/reporter.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
?
|
|
495
|
-
: withProviderRetry(
|
|
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 } : {}),
|