@north-light/crouter 0.3.304 → 0.3.305
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/dist/builtin-pi-packages/pi-crtr-extensions/extensions/__tests__/integration/provider-rotation.test.ts +87 -0
- package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/provider-rotation.js +31 -26
- package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/provider-rotation.ts +33 -26
- package/dist/clients/attach/viewer.js +581 -581
- package/dist/commands/node/create.js +7 -1
- package/dist/commands/node/wait.js +1 -1
- package/dist/commands/node-worktree.js +5 -1
- package/dist/core/__tests__/fixtures/fake-engine.js +14 -15
- package/dist/core/__tests__/integration/tmux-surface.test.js +53 -0
- package/dist/core/__tests__/kickoff.test.js +40 -0
- package/dist/core/__tests__/seam/broker-provider-retry.test.js +25 -0
- package/dist/core/__tests__/seam/deadline-wake-delivery.test.d.ts +1 -0
- package/dist/core/__tests__/seam/deadline-wake-delivery.test.js +138 -0
- package/dist/core/__tests__/seam/reopen-invalidated-worktree.test.d.ts +1 -0
- package/dist/core/__tests__/seam/reopen-invalidated-worktree.test.js +60 -0
- package/dist/core/fault-classifier.d.ts +6 -0
- package/dist/core/fault-classifier.js +9 -4
- package/dist/core/provider-failure.d.ts +30 -0
- package/dist/core/provider-failure.js +86 -0
- package/dist/core/runtime/broker/fault-retry.js +8 -0
- package/dist/core/runtime/fault.d.ts +2 -1
- package/dist/core/runtime/fault.js +38 -0
- package/dist/core/runtime/kickoff.d.ts +3 -0
- package/dist/core/runtime/kickoff.js +24 -4
- package/dist/core/runtime/reopen.d.ts +2 -2
- package/dist/core/runtime/reopen.js +6 -4
- package/dist/core/runtime/revive.js +3 -3
- package/dist/core/runtime/tmux-bindings.js +14 -3
- package/dist/core/termrender/version.d.ts +1 -1
- package/dist/core/termrender/version.js +1 -1
- package/dist/core/worktree-close.js +6 -3
- package/dist/core/worktree-sweep.js +7 -3
- package/dist/core/worktree.js +6 -3
- package/dist/daemon/api/__tests__/reopen-delivery.test.js +31 -0
- package/dist/daemon/api/__tests__/seam/leaf-api-parity.test.js +36 -4
- package/dist/daemon/api/handlers/messages.js +26 -9
- package/dist/daemon/reconcilers/broker-supervision.js +10 -1
- package/package.json +1 -1
- package/runtime.lock.json +5 -5
|
@@ -1251,6 +1251,93 @@ test("surfaces the underlying transient error without reporting a rate limit whe
|
|
|
1251
1251
|
}
|
|
1252
1252
|
});
|
|
1253
1253
|
|
|
1254
|
+
test("carries an observed 503 into the terminal provider error after bounded recovery", async () => {
|
|
1255
|
+
await resetRotationState();
|
|
1256
|
+
writeLadderConfig();
|
|
1257
|
+
await helpers.writeSubscriptionPool("openai-codex", [credential("codex")]);
|
|
1258
|
+
|
|
1259
|
+
const { ctx, providers, events, pi } = makeRotationCtx(undefined);
|
|
1260
|
+
rotationModule.__setSleepForTest(async () => {});
|
|
1261
|
+
rotationModule.__setStreamForProviderForTest((model, _context, options) => (async function* () {
|
|
1262
|
+
await options?.onResponse?.({ status: 503, headers: {} }, model);
|
|
1263
|
+
yield { type: "error", error: new Error("Request failed", { cause: Object.assign(new Error("socket reset"), { code: "ECONNRESET" }) }) };
|
|
1264
|
+
})() as any);
|
|
1265
|
+
|
|
1266
|
+
try {
|
|
1267
|
+
await providerRotationExtension(pi);
|
|
1268
|
+
await events.session_start?.({}, ctx);
|
|
1269
|
+
const emitted = [] as any[];
|
|
1270
|
+
for await (const event of providers["openai-codex"].streamSimple(ctx.model, ctx)) emitted.push(event);
|
|
1271
|
+
|
|
1272
|
+
const terminal = emitted.find((event) => event.type === "error");
|
|
1273
|
+
assert.ok(terminal, "the bounded attempts settle a terminal provider error");
|
|
1274
|
+
assert.equal(terminal.error.provider, "openai-codex");
|
|
1275
|
+
assert.equal(terminal.error.model, "gpt-5.6-sol");
|
|
1276
|
+
const details = terminal.error.diagnostics?.find((diagnostic: any) => diagnostic.type === "crtr:provider-failure")?.details;
|
|
1277
|
+
assert.equal(details?.status, 503, "the response status survives the stream error boundary");
|
|
1278
|
+
assert.equal(details?.causes?.[0]?.code, "ECONNRESET", "the cause chain survives the stream error boundary");
|
|
1279
|
+
} finally {
|
|
1280
|
+
rotationModule.__setStreamForProviderForTest(undefined);
|
|
1281
|
+
rotationModule.__setSleepForTest(undefined);
|
|
1282
|
+
}
|
|
1283
|
+
});
|
|
1284
|
+
|
|
1285
|
+
test("does not replay a partially exposed response, while retaining its 503 facts", async () => {
|
|
1286
|
+
await resetRotationState();
|
|
1287
|
+
writeLadderConfig();
|
|
1288
|
+
await helpers.writeSubscriptionPool("anthropic", [credential("primary")]);
|
|
1289
|
+
|
|
1290
|
+
const { ctx, setModelCalls, providers, events, pi } = makeRotationCtx();
|
|
1291
|
+
let attempts = 0;
|
|
1292
|
+
rotationModule.__setStreamForProviderForTest((model, _context, options) => (async function* () {
|
|
1293
|
+
attempts += 1;
|
|
1294
|
+
yield { type: "text_delta", text: "already exposed" };
|
|
1295
|
+
await options?.onResponse?.({ status: 503, headers: {} }, model);
|
|
1296
|
+
yield { type: "error", error: new Error("Request failed") };
|
|
1297
|
+
})() as any);
|
|
1298
|
+
|
|
1299
|
+
try {
|
|
1300
|
+
await providerRotationExtension(pi);
|
|
1301
|
+
await events.session_start?.({}, ctx);
|
|
1302
|
+
const emitted = [] as any[];
|
|
1303
|
+
for await (const event of providers["anthropic"].streamSimple(ctx.model, ctx)) emitted.push(event);
|
|
1304
|
+
|
|
1305
|
+
assert.equal(attempts, 1, "content commits the attempt before its failure");
|
|
1306
|
+
assert.ok(setModelCalls.every((selected) => selected.provider === "anthropic"), "a partial response never rotates to another route");
|
|
1307
|
+
const terminal = emitted.find((event) => event.type === "error");
|
|
1308
|
+
assert.equal(terminal.error.diagnostics?.find((diagnostic: any) => diagnostic.type === "crtr:provider-failure")?.details?.status, 503);
|
|
1309
|
+
} finally {
|
|
1310
|
+
rotationModule.__setStreamForProviderForTest(undefined);
|
|
1311
|
+
}
|
|
1312
|
+
});
|
|
1313
|
+
|
|
1314
|
+
test("keeps a known authentication rejection fatal", async () => {
|
|
1315
|
+
await resetRotationState();
|
|
1316
|
+
writeLadderConfig();
|
|
1317
|
+
await helpers.writeSubscriptionPool("openai-codex", [credential("codex")]);
|
|
1318
|
+
|
|
1319
|
+
const { ctx, providers, events, pi } = makeRotationCtx(undefined);
|
|
1320
|
+
let attempts = 0;
|
|
1321
|
+
rotationModule.__setStreamForProviderForTest((model, _context, options) => (async function* () {
|
|
1322
|
+
attempts += 1;
|
|
1323
|
+
await options?.onResponse?.({ status: 401, headers: {} }, model);
|
|
1324
|
+
yield { type: "error", error: new Error("Request failed") };
|
|
1325
|
+
})() as any);
|
|
1326
|
+
|
|
1327
|
+
try {
|
|
1328
|
+
await providerRotationExtension(pi);
|
|
1329
|
+
await events.session_start?.({}, ctx);
|
|
1330
|
+
const emitted = [] as any[];
|
|
1331
|
+
for await (const event of providers["openai-codex"].streamSimple(ctx.model, ctx)) emitted.push(event);
|
|
1332
|
+
|
|
1333
|
+
assert.equal(attempts, 1, "a protocol/auth failure never enters transient retries");
|
|
1334
|
+
const terminal = emitted.find((event) => event.type === "error");
|
|
1335
|
+
assert.equal(terminal.error.diagnostics?.find((diagnostic: any) => diagnostic.type === "crtr:provider-failure")?.details?.status, 401);
|
|
1336
|
+
} finally {
|
|
1337
|
+
rotationModule.__setStreamForProviderForTest(undefined);
|
|
1338
|
+
}
|
|
1339
|
+
});
|
|
1340
|
+
|
|
1254
1341
|
// Regression: a transport-poisoned process (a corrupted TLS upload leaves undici's H2
|
|
1255
1342
|
// session destroyed, so EVERY request fails with no HTTP response while fresh processes
|
|
1256
1343
|
// succeed) must self-terminate after consecutive transport-only exhausted passes so the
|
|
@@ -8,6 +8,7 @@ import { envModelExact, envNodeId } from '../../../shared/env.js';
|
|
|
8
8
|
import { ManagedProviderCoolingError } from '../../../core/runtime/managed-provider-cooling.js';
|
|
9
9
|
import { recordTransportPoisonVerdict } from '../../../core/runtime/busy.js';
|
|
10
10
|
import { recognizeProviderError } from '../../../core/provider-error-recognition.js';
|
|
11
|
+
import { normalizeProviderFailure, providerFailureDiagnostic } from '../../../core/provider-failure.js';
|
|
11
12
|
// A response without Retry-After proves only that THIS attempt was throttled; it
|
|
12
13
|
// does not authorize crouter to invent a five-minute provider-wide outage. Keep
|
|
13
14
|
// a short turn-local probe backoff to avoid an immediate retry loop, then let a
|
|
@@ -59,30 +60,31 @@ function classifyProviderSignal(signal) {
|
|
|
59
60
|
}
|
|
60
61
|
return { kind: 'fatal', status: signal.status, reason: 'unclassified', hadRetryAfterHeader };
|
|
61
62
|
}
|
|
62
|
-
function
|
|
63
|
-
const
|
|
64
|
-
const
|
|
63
|
+
function classifyProviderFailure(failure, retryAfterMs) {
|
|
64
|
+
const nested = failure.causes ?? [];
|
|
65
|
+
const code = failure.code ?? nested.find((cause) => cause.code !== undefined)?.code;
|
|
66
|
+
const errno = failure.errno ?? nested.find((cause) => cause.errno !== undefined)?.errno;
|
|
67
|
+
const message = [failure.message, failure.body, ...nested.flatMap((cause) => [cause.message, cause.body])]
|
|
65
68
|
.filter((part) => typeof part === 'string' && part.length > 0)
|
|
66
69
|
.join(' ');
|
|
67
70
|
return classifyProviderSignal(recognizeProviderError({
|
|
68
|
-
status:
|
|
69
|
-
retryAfterMs
|
|
71
|
+
status: failure.status,
|
|
72
|
+
retryAfterMs,
|
|
73
|
+
code,
|
|
74
|
+
errno,
|
|
70
75
|
message,
|
|
71
76
|
}));
|
|
72
77
|
}
|
|
78
|
+
function classifyProviderError(error, observed) {
|
|
79
|
+
return classifyProviderFailure(normalizeProviderFailure(error, { status: observed.status }), observed.retryAfterMs);
|
|
80
|
+
}
|
|
73
81
|
/** Test seam for the policy mapping over the shared recognition signal. */
|
|
74
82
|
export function __classifyProviderErrorForTest(error, observed = {}) {
|
|
75
83
|
return classifyProviderError(error, observed);
|
|
76
84
|
}
|
|
77
|
-
function
|
|
78
|
-
const
|
|
79
|
-
|
|
80
|
-
? value.errorMessage.trim()
|
|
81
|
-
: typeof value?.message === "string" && value.message.trim()
|
|
82
|
-
? value.message.trim()
|
|
83
|
-
: error instanceof Error && error.message.trim()
|
|
84
|
-
? error.message.trim()
|
|
85
|
-
: undefined;
|
|
85
|
+
function providerFailureAssistantMessage(model, failure) {
|
|
86
|
+
const message = toAssistantError(model, failure.message);
|
|
87
|
+
message.diagnostics = [providerFailureDiagnostic(failure)];
|
|
86
88
|
return message;
|
|
87
89
|
}
|
|
88
90
|
async function defaultSleep(ms) {
|
|
@@ -435,6 +437,7 @@ async function streamProviderAttempt(state, providerId, model, context, options,
|
|
|
435
437
|
let sawContent = false;
|
|
436
438
|
let committed = false;
|
|
437
439
|
let classification;
|
|
440
|
+
let failure;
|
|
438
441
|
let failureMessage;
|
|
439
442
|
// Events are buffered ONLY until this attempt commits to the provider -- i.e. until the
|
|
440
443
|
// first real content event (thinking/text/toolcall) arrives, after which rotating away is
|
|
@@ -462,12 +465,13 @@ async function streamProviderAttempt(state, providerId, model, context, options,
|
|
|
462
465
|
flush();
|
|
463
466
|
return { result: "fatal", attemptAt };
|
|
464
467
|
}
|
|
465
|
-
|
|
466
|
-
|
|
468
|
+
failure = normalizeProviderFailure(event.error, { status: observedStatus });
|
|
469
|
+
failureMessage = failure.message;
|
|
470
|
+
classification = classifyProviderFailure(failure, requestedRetryAfterMs);
|
|
467
471
|
if (classification.kind === "fatal" || sawContent) {
|
|
468
|
-
emit(event);
|
|
472
|
+
emit({ ...event, error: providerFailureAssistantMessage(model, failure) });
|
|
469
473
|
flush();
|
|
470
|
-
return { result: "fatal", attemptAt, classification };
|
|
474
|
+
return { result: "fatal", attemptAt, classification, failure };
|
|
471
475
|
}
|
|
472
476
|
break;
|
|
473
477
|
}
|
|
@@ -490,16 +494,17 @@ async function streamProviderAttempt(state, providerId, model, context, options,
|
|
|
490
494
|
flush();
|
|
491
495
|
return { result: "fatal", attemptAt };
|
|
492
496
|
}
|
|
493
|
-
|
|
494
|
-
|
|
497
|
+
failure = normalizeProviderFailure(error, { status: observedStatus });
|
|
498
|
+
failureMessage = failure.message;
|
|
499
|
+
classification = classifyProviderFailure(failure, requestedRetryAfterMs);
|
|
495
500
|
if (classification.kind === "fatal" || sawContent) {
|
|
496
501
|
emit({
|
|
497
502
|
type: "error",
|
|
498
503
|
reason: "error",
|
|
499
|
-
error:
|
|
504
|
+
error: providerFailureAssistantMessage(model, failure),
|
|
500
505
|
});
|
|
501
506
|
flush();
|
|
502
|
-
return { result: "fatal", attemptAt, classification };
|
|
507
|
+
return { result: "fatal", attemptAt, classification, failure };
|
|
503
508
|
}
|
|
504
509
|
}
|
|
505
510
|
if (!classification && requestedRetryAfterMs !== undefined && !sawContent) {
|
|
@@ -523,14 +528,14 @@ async function streamProviderAttempt(state, providerId, model, context, options,
|
|
|
523
528
|
// to another credential / fallback provider. Nothing is emitted into the stream here:
|
|
524
529
|
// the buffered (pre-content) events are discarded exactly like the rate-limit path, so
|
|
525
530
|
// the rotation target streams a clean turn.
|
|
526
|
-
return { result: "transient_exhausted", attemptAt, classification, attempts: attempt, failureMessage };
|
|
531
|
+
return { result: "transient_exhausted", attemptAt, classification, attempts: attempt, failure, failureMessage };
|
|
527
532
|
}
|
|
528
533
|
logRotation(`${credential ? `${getProviderLabel(providerId)} subscription "${credential.label}"` : providerId} hit a transient error (${classification.reason}, status=${classification.status ?? "n/a"}); retrying same route in ${backoffMs}ms (attempt ${attempt + 1})`);
|
|
529
534
|
await activeSleep(backoffMs);
|
|
530
535
|
}
|
|
531
536
|
}
|
|
532
|
-
function emitTerminalError(stream, model, message) {
|
|
533
|
-
stream.push({ type: "error", reason: "error", error: toAssistantError(model, message) });
|
|
537
|
+
function emitTerminalError(stream, model, message, failure) {
|
|
538
|
+
stream.push({ type: "error", reason: "error", error: failure === undefined ? toAssistantError(model, message) : providerFailureAssistantMessage(model, failure) });
|
|
534
539
|
stream.end();
|
|
535
540
|
}
|
|
536
541
|
function enqueueFreshRouteCandidates(candidates, queuedRouteIds, knownRoutes, seenReadyLabels, cooldownAttempts, localCoolingDeadlines) {
|
|
@@ -710,7 +715,7 @@ async function runManagedProvider(state, model, context, options, stream) {
|
|
|
710
715
|
return;
|
|
711
716
|
}
|
|
712
717
|
const detail = lastTransientFailure.failureMessage ?? lastTransientFailure.classification?.reason ?? 'unknown connection failure';
|
|
713
|
-
emitTerminalError(stream, terminalModel, `Provider routes failed after transient retries: ${detail} (tried ${tried})
|
|
718
|
+
emitTerminalError(stream, terminalModel, `Provider routes failed after transient retries: ${detail} (tried ${tried})`, lastTransientFailure.failure);
|
|
714
719
|
return;
|
|
715
720
|
}
|
|
716
721
|
state.transportOnlyExhaustedPasses = 0;
|
|
@@ -44,6 +44,7 @@ import { envModelExact, envNodeId } from '../../../shared/env.js';
|
|
|
44
44
|
import { ManagedProviderCoolingError } from '../../../core/runtime/managed-provider-cooling.js';
|
|
45
45
|
import { recordTransportPoisonVerdict } from '../../../core/runtime/busy.js';
|
|
46
46
|
import { recognizeProviderError, type ProviderErrorSignal } from '../../../core/provider-error-recognition.js';
|
|
47
|
+
import { normalizeProviderFailure, providerFailureDiagnostic, type NormalizedProviderFailure } from '../../../core/provider-failure.js';
|
|
47
48
|
|
|
48
49
|
// A response without Retry-After proves only that THIS attempt was throttled; it
|
|
49
50
|
// does not authorize crouter to invent a five-minute provider-wide outage. Keep
|
|
@@ -111,6 +112,7 @@ type AttemptResult = {
|
|
|
111
112
|
attemptAt: number;
|
|
112
113
|
attempts?: number;
|
|
113
114
|
classification?: ProviderErrorClassification;
|
|
115
|
+
failure?: NormalizedProviderFailure;
|
|
114
116
|
failureMessage?: string;
|
|
115
117
|
};
|
|
116
118
|
|
|
@@ -153,32 +155,34 @@ function classifyProviderSignal(signal: ProviderErrorSignal): ProviderErrorClass
|
|
|
153
155
|
return { kind: 'fatal', status: signal.status, reason: 'unclassified', hadRetryAfterHeader };
|
|
154
156
|
}
|
|
155
157
|
|
|
156
|
-
function
|
|
157
|
-
const
|
|
158
|
-
const
|
|
158
|
+
function classifyProviderFailure(failure: NormalizedProviderFailure, retryAfterMs: number | undefined): ProviderErrorClassification {
|
|
159
|
+
const nested = failure.causes ?? [];
|
|
160
|
+
const code = failure.code ?? nested.find((cause) => cause.code !== undefined)?.code;
|
|
161
|
+
const errno = failure.errno ?? nested.find((cause) => cause.errno !== undefined)?.errno;
|
|
162
|
+
const message = [failure.message, failure.body, ...nested.flatMap((cause) => [cause.message, cause.body])]
|
|
159
163
|
.filter((part): part is string => typeof part === 'string' && part.length > 0)
|
|
160
164
|
.join(' ');
|
|
161
165
|
return classifyProviderSignal(recognizeProviderError({
|
|
162
|
-
status:
|
|
163
|
-
retryAfterMs
|
|
166
|
+
status: failure.status,
|
|
167
|
+
retryAfterMs,
|
|
168
|
+
code,
|
|
169
|
+
errno,
|
|
164
170
|
message,
|
|
165
171
|
}));
|
|
166
172
|
}
|
|
167
173
|
|
|
174
|
+
function classifyProviderError(error: unknown, observed: { status?: number; retryAfterMs?: number }): ProviderErrorClassification {
|
|
175
|
+
return classifyProviderFailure(normalizeProviderFailure(error, { status: observed.status }), observed.retryAfterMs);
|
|
176
|
+
}
|
|
177
|
+
|
|
168
178
|
/** Test seam for the policy mapping over the shared recognition signal. */
|
|
169
179
|
export function __classifyProviderErrorForTest(error: unknown, observed: { status?: number; retryAfterMs?: number } = {}): ProviderErrorClassification {
|
|
170
180
|
return classifyProviderError(error, observed);
|
|
171
181
|
}
|
|
172
182
|
|
|
173
|
-
function
|
|
174
|
-
const
|
|
175
|
-
|
|
176
|
-
? value.errorMessage.trim()
|
|
177
|
-
: typeof value?.message === "string" && value.message.trim()
|
|
178
|
-
? value.message.trim()
|
|
179
|
-
: error instanceof Error && error.message.trim()
|
|
180
|
-
? error.message.trim()
|
|
181
|
-
: undefined;
|
|
183
|
+
function providerFailureAssistantMessage(model: Model<any>, failure: NormalizedProviderFailure): AssistantMessage {
|
|
184
|
+
const message = toAssistantError(model, failure.message);
|
|
185
|
+
message.diagnostics = [providerFailureDiagnostic(failure)];
|
|
182
186
|
return message;
|
|
183
187
|
}
|
|
184
188
|
|
|
@@ -599,6 +603,7 @@ async function streamProviderAttempt(
|
|
|
599
603
|
let sawContent = false;
|
|
600
604
|
let committed = false;
|
|
601
605
|
let classification: ProviderErrorClassification | undefined;
|
|
606
|
+
let failure: NormalizedProviderFailure | undefined;
|
|
602
607
|
let failureMessage: string | undefined;
|
|
603
608
|
|
|
604
609
|
// Events are buffered ONLY until this attempt commits to the provider -- i.e. until the
|
|
@@ -625,12 +630,13 @@ async function streamProviderAttempt(
|
|
|
625
630
|
flush();
|
|
626
631
|
return { result: "fatal", attemptAt };
|
|
627
632
|
}
|
|
628
|
-
|
|
629
|
-
|
|
633
|
+
failure = normalizeProviderFailure(event.error, { status: observedStatus });
|
|
634
|
+
failureMessage = failure.message;
|
|
635
|
+
classification = classifyProviderFailure(failure, requestedRetryAfterMs);
|
|
630
636
|
if (classification.kind === "fatal" || sawContent) {
|
|
631
|
-
emit(event);
|
|
637
|
+
emit({ ...event, error: providerFailureAssistantMessage(model, failure) });
|
|
632
638
|
flush();
|
|
633
|
-
return { result: "fatal", attemptAt, classification };
|
|
639
|
+
return { result: "fatal", attemptAt, classification, failure };
|
|
634
640
|
}
|
|
635
641
|
break;
|
|
636
642
|
}
|
|
@@ -650,16 +656,17 @@ async function streamProviderAttempt(
|
|
|
650
656
|
flush();
|
|
651
657
|
return { result: "fatal", attemptAt };
|
|
652
658
|
}
|
|
653
|
-
|
|
654
|
-
|
|
659
|
+
failure = normalizeProviderFailure(error, { status: observedStatus });
|
|
660
|
+
failureMessage = failure.message;
|
|
661
|
+
classification = classifyProviderFailure(failure, requestedRetryAfterMs);
|
|
655
662
|
if (classification.kind === "fatal" || sawContent) {
|
|
656
663
|
emit({
|
|
657
664
|
type: "error",
|
|
658
665
|
reason: "error",
|
|
659
|
-
error:
|
|
666
|
+
error: providerFailureAssistantMessage(model, failure),
|
|
660
667
|
});
|
|
661
668
|
flush();
|
|
662
|
-
return { result: "fatal", attemptAt, classification };
|
|
669
|
+
return { result: "fatal", attemptAt, classification, failure };
|
|
663
670
|
}
|
|
664
671
|
}
|
|
665
672
|
|
|
@@ -687,7 +694,7 @@ async function streamProviderAttempt(
|
|
|
687
694
|
// to another credential / fallback provider. Nothing is emitted into the stream here:
|
|
688
695
|
// the buffered (pre-content) events are discarded exactly like the rate-limit path, so
|
|
689
696
|
// the rotation target streams a clean turn.
|
|
690
|
-
return { result: "transient_exhausted", attemptAt, classification, attempts: attempt, failureMessage };
|
|
697
|
+
return { result: "transient_exhausted", attemptAt, classification, attempts: attempt, failure, failureMessage };
|
|
691
698
|
}
|
|
692
699
|
logRotation(
|
|
693
700
|
`${credential ? `${getProviderLabel(providerId as ManagedProviderId)} subscription "${credential.label}"` : providerId} hit a transient error (${classification.reason}, status=${classification.status ?? "n/a"}); retrying same route in ${backoffMs}ms (attempt ${attempt + 1})`,
|
|
@@ -696,8 +703,8 @@ async function streamProviderAttempt(
|
|
|
696
703
|
}
|
|
697
704
|
}
|
|
698
705
|
|
|
699
|
-
function emitTerminalError(stream: AssistantMessageEventStream, model: Model<any>, message: string): void {
|
|
700
|
-
stream.push({ type: "error", reason: "error", error: toAssistantError(model, message) });
|
|
706
|
+
function emitTerminalError(stream: AssistantMessageEventStream, model: Model<any>, message: string, failure?: NormalizedProviderFailure): void {
|
|
707
|
+
stream.push({ type: "error", reason: "error", error: failure === undefined ? toAssistantError(model, message) : providerFailureAssistantMessage(model, failure) });
|
|
701
708
|
stream.end();
|
|
702
709
|
}
|
|
703
710
|
|
|
@@ -895,7 +902,7 @@ async function runManagedProvider(
|
|
|
895
902
|
return;
|
|
896
903
|
}
|
|
897
904
|
const detail = lastTransientFailure.failureMessage ?? lastTransientFailure.classification?.reason ?? 'unknown connection failure';
|
|
898
|
-
emitTerminalError(stream, terminalModel, `Provider routes failed after transient retries: ${detail} (tried ${tried})
|
|
905
|
+
emitTerminalError(stream, terminalModel, `Provider routes failed after transient retries: ${detail} (tried ${tried})`, lastTransientFailure.failure);
|
|
899
906
|
return;
|
|
900
907
|
}
|
|
901
908
|
state.transportOnlyExhaustedPasses = 0;
|