@north-light/crouter 0.3.172 → 0.3.173
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__/provider-rotation.test.ts +81 -0
- package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/provider-rotation.js +49 -0
- package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/provider-rotation.ts +52 -0
- package/package.json +1 -1
- package/runtime.lock.json +2 -2
package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/__tests__/provider-rotation.test.ts
CHANGED
|
@@ -1001,6 +1001,87 @@ function makeRotationCtx(modelIntent?: string) {
|
|
|
1001
1001
|
return { ctx, setModelCalls, thinkingLevelCalls, providers, events, commands, pi };
|
|
1002
1002
|
}
|
|
1003
1003
|
|
|
1004
|
+
test("managed Anthropic requests opt into server-side fallback without changing other managed routes", async () => {
|
|
1005
|
+
await resetRotationState();
|
|
1006
|
+
writeLadderConfig();
|
|
1007
|
+
await helpers.writeSubscriptionPool("anthropic", [credential("anthropic")]);
|
|
1008
|
+
|
|
1009
|
+
const anthropicModel = { provider: "anthropic", id: "claude-opus-4-8", api: "anthropic-messages" };
|
|
1010
|
+
const { ctx, providers, events, pi } = makeRotationCtx("anthropic/strong");
|
|
1011
|
+
ctx.model = anthropicModel;
|
|
1012
|
+
const streamOptions: any[] = [];
|
|
1013
|
+
const fetchCalls: Array<[RequestInfo | URL, RequestInit | undefined]> = [];
|
|
1014
|
+
const callerPayloads: unknown[] = [];
|
|
1015
|
+
rotationModule.__setStreamForProviderForTest((_model, _context, options) => {
|
|
1016
|
+
streamOptions.push(options);
|
|
1017
|
+
return (async function* () {
|
|
1018
|
+
yield { type: "text_delta", text: "served" };
|
|
1019
|
+
})() as any;
|
|
1020
|
+
});
|
|
1021
|
+
|
|
1022
|
+
try {
|
|
1023
|
+
await providerRotationExtension(pi);
|
|
1024
|
+
await events.session_start?.({}, ctx);
|
|
1025
|
+
const stream = providers["anthropic"].streamSimple(anthropicModel, ctx, {
|
|
1026
|
+
onPayload(payload: unknown) {
|
|
1027
|
+
callerPayloads.push(payload);
|
|
1028
|
+
if ((payload as { replaceWithNull?: boolean })?.replaceWithNull) return null;
|
|
1029
|
+
return { ...(payload as object), callerPayload: true };
|
|
1030
|
+
},
|
|
1031
|
+
fetch: async (input: RequestInfo | URL, init?: RequestInit) => {
|
|
1032
|
+
fetchCalls.push([input, init]);
|
|
1033
|
+
return new Response();
|
|
1034
|
+
},
|
|
1035
|
+
});
|
|
1036
|
+
for await (const _event of stream) {
|
|
1037
|
+
// Drain the managed request so the injected stream options are captured.
|
|
1038
|
+
}
|
|
1039
|
+
|
|
1040
|
+
const [anthropicOptions] = streamOptions;
|
|
1041
|
+
assert.deepEqual(await anthropicOptions.onPayload({ request: true }, anthropicModel), {
|
|
1042
|
+
request: true,
|
|
1043
|
+
callerPayload: true,
|
|
1044
|
+
fallbacks: "default",
|
|
1045
|
+
});
|
|
1046
|
+
assert.deepEqual(await anthropicOptions.onPayload({ replaceWithNull: true }, anthropicModel), { fallbacks: "default" });
|
|
1047
|
+
assert.deepEqual(callerPayloads, [{ request: true }, { replaceWithNull: true }]);
|
|
1048
|
+
|
|
1049
|
+
for (const headers of [
|
|
1050
|
+
new Headers({ "anthropic-beta": "claude-code-20250219,oauth-2025-04-20", "x-keep": "headers" }),
|
|
1051
|
+
{ "anthropic-beta": "claude-code-20250219,oauth-2025-04-20", "x-keep": "object" },
|
|
1052
|
+
[["anthropic-beta", "claude-code-20250219,oauth-2025-04-20"], ["x-keep", "pairs"]],
|
|
1053
|
+
]) {
|
|
1054
|
+
await anthropicOptions.fetch("https://example.test/messages", { headers });
|
|
1055
|
+
}
|
|
1056
|
+
for (const [_input, init] of fetchCalls) {
|
|
1057
|
+
const headers = new Headers(init?.headers);
|
|
1058
|
+
assert.equal(headers.get("anthropic-beta"), "claude-code-20250219,oauth-2025-04-20,server-side-fallback-2026-07-01");
|
|
1059
|
+
assert.ok(headers.get("x-keep"));
|
|
1060
|
+
}
|
|
1061
|
+
|
|
1062
|
+
await resetRotationState();
|
|
1063
|
+
await helpers.writeSubscriptionPool("openai-codex", [credential("codex")]);
|
|
1064
|
+
const codexModel = { provider: "openai-codex", id: "gpt-5.6-sol", api: "openai-codex-responses" };
|
|
1065
|
+
const codex = makeRotationCtx("openai/strong");
|
|
1066
|
+
const codexOptions: any[] = [];
|
|
1067
|
+
rotationModule.__setStreamForProviderForTest((_model, _context, options) => {
|
|
1068
|
+
codexOptions.push(options);
|
|
1069
|
+
return (async function* () {
|
|
1070
|
+
yield { type: "text_delta", text: "served" };
|
|
1071
|
+
})() as any;
|
|
1072
|
+
});
|
|
1073
|
+
await providerRotationExtension(codex.pi);
|
|
1074
|
+
await codex.events.session_start?.({}, codex.ctx);
|
|
1075
|
+
for await (const _event of codex.providers["openai-codex"].streamSimple(codexModel, codex.ctx)) {
|
|
1076
|
+
// Drain the managed Codex route.
|
|
1077
|
+
}
|
|
1078
|
+
assert.equal(codexOptions[0].onPayload, undefined);
|
|
1079
|
+
assert.equal(codexOptions[0].fetch, undefined);
|
|
1080
|
+
} finally {
|
|
1081
|
+
rotationModule.__setStreamForProviderForTest(undefined);
|
|
1082
|
+
}
|
|
1083
|
+
});
|
|
1084
|
+
|
|
1004
1085
|
// A live route carries the session's exact thinking level through every provider
|
|
1005
1086
|
// request, including the requests that continue a turn after tool results.
|
|
1006
1087
|
test("preserves a manually selected thinking level across provider requests", async () => {
|
|
@@ -280,6 +280,42 @@ let activeStreamForProvider = defaultStreamForProvider;
|
|
|
280
280
|
function streamForProvider(model, context, options) {
|
|
281
281
|
return activeStreamForProvider(model, context, options);
|
|
282
282
|
}
|
|
283
|
+
const ANTHROPIC_SERVER_SIDE_FALLBACK_BETA = "server-side-fallback-2026-07-01";
|
|
284
|
+
function includesAnthropicBetaToken(value) {
|
|
285
|
+
return value.split(",").some((token) => token.trim() === ANTHROPIC_SERVER_SIDE_FALLBACK_BETA);
|
|
286
|
+
}
|
|
287
|
+
function appendAnthropicFallbackBeta(headers) {
|
|
288
|
+
if (headers instanceof Headers) {
|
|
289
|
+
const next = new Headers(headers);
|
|
290
|
+
const current = next.get("anthropic-beta");
|
|
291
|
+
if (current === null || !includesAnthropicBetaToken(current)) {
|
|
292
|
+
next.set("anthropic-beta", current === null ? ANTHROPIC_SERVER_SIDE_FALLBACK_BETA : `${current},${ANTHROPIC_SERVER_SIDE_FALLBACK_BETA}`);
|
|
293
|
+
}
|
|
294
|
+
return next;
|
|
295
|
+
}
|
|
296
|
+
if (Array.isArray(headers)) {
|
|
297
|
+
const next = headers.map(([name, value]) => [name, value]);
|
|
298
|
+
const index = next.findIndex(([name]) => name.toLowerCase() === "anthropic-beta");
|
|
299
|
+
if (index === -1)
|
|
300
|
+
next.push(["anthropic-beta", ANTHROPIC_SERVER_SIDE_FALLBACK_BETA]);
|
|
301
|
+
else if (!includesAnthropicBetaToken(next[index][1]))
|
|
302
|
+
next[index][1] = `${next[index][1]},${ANTHROPIC_SERVER_SIDE_FALLBACK_BETA}`;
|
|
303
|
+
return next;
|
|
304
|
+
}
|
|
305
|
+
const next = { ...(headers ?? {}) };
|
|
306
|
+
const headerName = Object.keys(next).find((name) => name.toLowerCase() === "anthropic-beta");
|
|
307
|
+
if (headerName === undefined)
|
|
308
|
+
next["anthropic-beta"] = ANTHROPIC_SERVER_SIDE_FALLBACK_BETA;
|
|
309
|
+
else if (!includesAnthropicBetaToken(next[headerName]))
|
|
310
|
+
next[headerName] = `${next[headerName]},${ANTHROPIC_SERVER_SIDE_FALLBACK_BETA}`;
|
|
311
|
+
return next;
|
|
312
|
+
}
|
|
313
|
+
function withAnthropicFallbackBeta(fetch) {
|
|
314
|
+
return (input, init) => {
|
|
315
|
+
const headers = init?.headers ?? (input instanceof Request ? input.headers : undefined);
|
|
316
|
+
return fetch(input, { ...init, headers: appendAnthropicFallbackBeta(headers) });
|
|
317
|
+
};
|
|
318
|
+
}
|
|
283
319
|
/** Test-only seam: override the per-provider stream factory. Pass undefined to restore the default. */
|
|
284
320
|
export function __setStreamForProviderForTest(fn) {
|
|
285
321
|
activeStreamForProvider = fn ?? defaultStreamForProvider;
|
|
@@ -295,9 +331,22 @@ async function streamProviderAttempt(state, providerId, model, context, options,
|
|
|
295
331
|
const attemptAt = Date.now();
|
|
296
332
|
let observedStatus;
|
|
297
333
|
let requestedRetryAfterMs;
|
|
334
|
+
const useAnthropicServerSideFallback = credential !== undefined && providerId === ANTHROPIC_PROVIDER_ID;
|
|
298
335
|
const streamOptions = {
|
|
299
336
|
...options,
|
|
300
337
|
...(credential ? { apiKey: credential.access } : {}),
|
|
338
|
+
...(useAnthropicServerSideFallback
|
|
339
|
+
? {
|
|
340
|
+
onPayload: async (payload, payloadModel) => {
|
|
341
|
+
const callerPayload = await options?.onPayload?.(payload, payloadModel);
|
|
342
|
+
return { ...(callerPayload === undefined ? payload : callerPayload), fallbacks: "default" };
|
|
343
|
+
},
|
|
344
|
+
// `headers` would overwrite pi-ai's OAuth beta list. Compose at fetch time so the
|
|
345
|
+
// SDK's generated headers stay intact; replace this shim with pi-ai's `fallbacks`
|
|
346
|
+
// option when that upstream option ships.
|
|
347
|
+
fetch: withAnthropicFallbackBeta(options?.fetch ?? globalThis.fetch),
|
|
348
|
+
}
|
|
349
|
+
: {}),
|
|
301
350
|
onResponse: async (response, responseModel) => {
|
|
302
351
|
observedStatus = response.status;
|
|
303
352
|
if (response.status === 429 || response.status === 503) {
|
|
@@ -7,6 +7,7 @@ import {
|
|
|
7
7
|
type AuthEvent,
|
|
8
8
|
type AuthPrompt,
|
|
9
9
|
type Context,
|
|
10
|
+
type FetchFunction,
|
|
10
11
|
type Model,
|
|
11
12
|
type OAuthAuth,
|
|
12
13
|
type SimpleStreamOptions,
|
|
@@ -397,6 +398,44 @@ function streamForProvider(model: Model<any>, context: Context, options: SimpleS
|
|
|
397
398
|
return activeStreamForProvider(model, context, options);
|
|
398
399
|
}
|
|
399
400
|
|
|
401
|
+
const ANTHROPIC_SERVER_SIDE_FALLBACK_BETA = "server-side-fallback-2026-07-01";
|
|
402
|
+
|
|
403
|
+
function includesAnthropicBetaToken(value: string): boolean {
|
|
404
|
+
return value.split(",").some((token) => token.trim() === ANTHROPIC_SERVER_SIDE_FALLBACK_BETA);
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
function appendAnthropicFallbackBeta(headers: HeadersInit | undefined): HeadersInit {
|
|
408
|
+
if (headers instanceof Headers) {
|
|
409
|
+
const next = new Headers(headers);
|
|
410
|
+
const current = next.get("anthropic-beta");
|
|
411
|
+
if (current === null || !includesAnthropicBetaToken(current)) {
|
|
412
|
+
next.set("anthropic-beta", current === null ? ANTHROPIC_SERVER_SIDE_FALLBACK_BETA : `${current},${ANTHROPIC_SERVER_SIDE_FALLBACK_BETA}`);
|
|
413
|
+
}
|
|
414
|
+
return next;
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
if (Array.isArray(headers)) {
|
|
418
|
+
const next = headers.map(([name, value]) => [name, value] as [string, string]);
|
|
419
|
+
const index = next.findIndex(([name]) => name.toLowerCase() === "anthropic-beta");
|
|
420
|
+
if (index === -1) next.push(["anthropic-beta", ANTHROPIC_SERVER_SIDE_FALLBACK_BETA]);
|
|
421
|
+
else if (!includesAnthropicBetaToken(next[index][1])) next[index][1] = `${next[index][1]},${ANTHROPIC_SERVER_SIDE_FALLBACK_BETA}`;
|
|
422
|
+
return next;
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
const next = { ...(headers ?? {}) } as Record<string, string>;
|
|
426
|
+
const headerName = Object.keys(next).find((name) => name.toLowerCase() === "anthropic-beta");
|
|
427
|
+
if (headerName === undefined) next["anthropic-beta"] = ANTHROPIC_SERVER_SIDE_FALLBACK_BETA;
|
|
428
|
+
else if (!includesAnthropicBetaToken(next[headerName])) next[headerName] = `${next[headerName]},${ANTHROPIC_SERVER_SIDE_FALLBACK_BETA}`;
|
|
429
|
+
return next;
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
function withAnthropicFallbackBeta(fetch: FetchFunction): FetchFunction {
|
|
433
|
+
return (input, init) => {
|
|
434
|
+
const headers = init?.headers ?? (input instanceof Request ? input.headers : undefined);
|
|
435
|
+
return fetch(input, { ...init, headers: appendAnthropicFallbackBeta(headers) });
|
|
436
|
+
};
|
|
437
|
+
}
|
|
438
|
+
|
|
400
439
|
/** Test-only seam: override the per-provider stream factory. Pass undefined to restore the default. */
|
|
401
440
|
export function __setStreamForProviderForTest(fn: typeof defaultStreamForProvider | undefined): void {
|
|
402
441
|
activeStreamForProvider = fn ?? defaultStreamForProvider;
|
|
@@ -421,9 +460,22 @@ async function streamProviderAttempt(
|
|
|
421
460
|
const attemptAt = Date.now();
|
|
422
461
|
let observedStatus: number | undefined;
|
|
423
462
|
let requestedRetryAfterMs: number | undefined;
|
|
463
|
+
const useAnthropicServerSideFallback = credential !== undefined && providerId === ANTHROPIC_PROVIDER_ID;
|
|
424
464
|
const streamOptions = {
|
|
425
465
|
...options,
|
|
426
466
|
...(credential ? { apiKey: credential.access } : {}),
|
|
467
|
+
...(useAnthropicServerSideFallback
|
|
468
|
+
? {
|
|
469
|
+
onPayload: async (payload: unknown, payloadModel: Model<any>) => {
|
|
470
|
+
const callerPayload = await options?.onPayload?.(payload, payloadModel);
|
|
471
|
+
return { ...((callerPayload === undefined ? payload : callerPayload) as Record<string, unknown>), fallbacks: "default" };
|
|
472
|
+
},
|
|
473
|
+
// `headers` would overwrite pi-ai's OAuth beta list. Compose at fetch time so the
|
|
474
|
+
// SDK's generated headers stay intact; replace this shim with pi-ai's `fallbacks`
|
|
475
|
+
// option when that upstream option ships.
|
|
476
|
+
fetch: withAnthropicFallbackBeta(options?.fetch ?? globalThis.fetch),
|
|
477
|
+
}
|
|
478
|
+
: {}),
|
|
427
479
|
onResponse: async (response: { status: number; headers: Record<string, string> }, responseModel: Model<any>) => {
|
|
428
480
|
observedStatus = response.status;
|
|
429
481
|
if (response.status === 429 || response.status === 503) {
|
package/package.json
CHANGED
package/runtime.lock.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@north-light/crouter",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.173",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "@north-light/crouter",
|
|
9
|
-
"version": "0.3.
|
|
9
|
+
"version": "0.3.173",
|
|
10
10
|
"hasInstallScript": true,
|
|
11
11
|
"license": "MIT",
|
|
12
12
|
"dependencies": {
|