@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.
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@north-light/crouter",
3
- "version": "0.3.172",
3
+ "version": "0.3.173",
4
4
  "description": "crtr — agent runtime with memory, plugins, and marketplaces",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
package/runtime.lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@north-light/crouter",
3
- "version": "0.3.172",
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.172",
9
+ "version": "0.3.173",
10
10
  "hasInstallScript": true,
11
11
  "license": "MIT",
12
12
  "dependencies": {