@mupt-ai/dari-gateway 0.3.1 → 0.3.3

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 (110) hide show
  1. package/README.md +18 -11
  2. package/dari-gateway.example.yaml +3 -2
  3. package/dist/context/interaction_fingerprint.js +10 -1
  4. package/dist/http/auth.js +2 -2
  5. package/dist/http/http2_server.d.ts +1 -1
  6. package/dist/http/http2_server.js +3 -3
  7. package/dist/http/serve.d.ts +1 -0
  8. package/dist/http/serve.js +5 -4
  9. package/dist/ingress/chat_completions.js +2 -2
  10. package/dist/ingress/codex.js +7 -3
  11. package/dist/ingress/codex_models.d.ts +1 -1
  12. package/dist/ingress/codex_models.js +22 -13
  13. package/dist/platform/platform.d.ts +13 -3
  14. package/dist/protocol/input/openai_responses.js +2 -4
  15. package/dist/protocol/output/anthropic_messages_output.d.ts +2 -2
  16. package/dist/protocol/output/anthropic_messages_output.js +1 -0
  17. package/dist/protocol/output/openai_responses_stream.js +19 -1
  18. package/dist/providers/execution.d.ts +0 -5
  19. package/dist/providers/execution.js +5 -8
  20. package/dist/providers/pi_output.d.ts +4 -0
  21. package/dist/providers/pi_output.js +1 -1
  22. package/dist/providers/provider_attempt_runner.d.ts +2 -3
  23. package/dist/providers/provider_attempt_runner.js +14 -24
  24. package/dist/providers/provider_call.d.ts +3 -3
  25. package/dist/providers/provider_call.js +1 -0
  26. package/dist/providers/stream_finalization.d.ts +0 -1
  27. package/dist/providers/stream_finalization.js +0 -1
  28. package/dist/runtime/claude_code/auxiliary_routing.js +1 -9
  29. package/dist/runtime/explicit_model.js +1 -1
  30. package/dist/runtime/harness_identity/conversation.d.ts +9 -0
  31. package/dist/runtime/harness_identity/conversation.js +6 -0
  32. package/dist/runtime/harness_identity/extract.d.ts +2 -0
  33. package/dist/runtime/harness_identity/extract.js +20 -5
  34. package/dist/runtime/harness_identity/index.d.ts +2 -2
  35. package/dist/runtime/harness_identity/index.js +2 -2
  36. package/dist/runtime/prepare_routed_completion.d.ts +5 -1
  37. package/dist/runtime/prepare_routed_completion.js +26 -2
  38. package/dist/runtime/routed_runtime.js +33 -53
  39. package/dist/runtime/routing_lease.d.ts +4 -6
  40. package/dist/runtime/routing_lease.js +2 -47
  41. package/dist/runtime/runtime_reporter.d.ts +0 -2
  42. package/dist/runtime/runtime_reporter.js +0 -1
  43. package/dist/runtime/session_pin.d.ts +7 -0
  44. package/dist/runtime/session_pin.js +51 -0
  45. package/dist/self_host/config.d.ts +2 -3
  46. package/dist/self_host/config.js +3 -3
  47. package/dist/self_host/main.d.ts +2 -1
  48. package/dist/self_host/main.js +26 -6
  49. package/dist/self_host/platform.d.ts +4 -2
  50. package/dist/self_host/platform.js +6 -2
  51. package/dist/self_host/resolve.d.ts +8 -1
  52. package/dist/self_host/resolve.js +31 -8
  53. package/dist/self_host/session_pin_store.d.ts +14 -0
  54. package/dist/self_host/session_pin_store.js +44 -0
  55. package/dist/strategies/priority_selector.d.ts +1 -2
  56. package/dist/strategies/priority_selector.js +1 -2
  57. package/dist/strategies/route_decision.d.ts +0 -1
  58. package/dist/strategies/route_decision.js +0 -3
  59. package/dist/strategies/routing.d.ts +0 -3
  60. package/dist/strategies/routing.js +1 -14
  61. package/dist/telemetry/billing_finalization.d.ts +1 -2
  62. package/dist/telemetry/billing_finalization.js +1 -1
  63. package/dist/telemetry/prefix_state.d.ts +0 -1
  64. package/dist/telemetry/prefix_state.js +1 -3
  65. package/dist/telemetry/report.d.ts +0 -2
  66. package/dist/telemetry/report.js +5 -41
  67. package/dist/types.d.ts +5 -3
  68. package/package.json +2 -2
  69. package/src/context/interaction_fingerprint.ts +15 -1
  70. package/src/http/auth.ts +2 -2
  71. package/src/http/http2_server.ts +7 -3
  72. package/src/http/serve.ts +6 -5
  73. package/src/ingress/chat_completions.ts +2 -1
  74. package/src/ingress/codex.ts +8 -5
  75. package/src/ingress/codex_models.ts +18 -7
  76. package/src/platform/platform.ts +16 -2
  77. package/src/protocol/input/openai_responses.ts +2 -9
  78. package/src/protocol/output/anthropic_messages_output.ts +3 -2
  79. package/src/protocol/output/openai_responses_stream.ts +17 -1
  80. package/src/providers/execution.ts +5 -15
  81. package/src/providers/pi_output.ts +1 -1
  82. package/src/providers/provider_attempt_runner.ts +10 -21
  83. package/src/providers/provider_call.ts +3 -1
  84. package/src/providers/stream_finalization.ts +0 -2
  85. package/src/providers/subscription_limits.ts +1 -1
  86. package/src/runtime/claude_code/auxiliary_routing.ts +1 -8
  87. package/src/runtime/explicit_model.ts +1 -1
  88. package/src/runtime/harness_identity/conversation.ts +16 -0
  89. package/src/runtime/harness_identity/extract.ts +24 -5
  90. package/src/runtime/harness_identity/index.ts +3 -0
  91. package/src/runtime/prepare_routed_completion.ts +32 -2
  92. package/src/runtime/routed_runtime.ts +39 -59
  93. package/src/runtime/routing_lease.ts +2 -56
  94. package/src/runtime/runtime_reporter.ts +0 -3
  95. package/src/runtime/session_pin.ts +61 -0
  96. package/src/self_host/config.ts +7 -7
  97. package/src/self_host/main.ts +29 -6
  98. package/src/self_host/platform.ts +8 -2
  99. package/src/self_host/resolve.ts +37 -7
  100. package/src/self_host/session_pin_store.ts +58 -0
  101. package/src/strategies/priority_selector.ts +1 -2
  102. package/src/strategies/route_decision.ts +0 -5
  103. package/src/strategies/routing.ts +1 -17
  104. package/src/telemetry/billing_finalization.ts +4 -3
  105. package/src/telemetry/prefix_state.ts +1 -6
  106. package/src/telemetry/report.ts +4 -48
  107. package/src/types.ts +12 -6
  108. package/dist/strategies/fallback_decision.d.ts +0 -8
  109. package/dist/strategies/fallback_decision.js +0 -17
  110. package/src/strategies/fallback_decision.ts +0 -30
@@ -58,11 +58,8 @@ import {
58
58
  export type ProviderChoice = {
59
59
  decision: RoutingDecision;
60
60
  execution: ProviderExecution;
61
- role: "primary" | "fallback";
62
61
  };
63
62
 
64
- export type ResolveFallbackChoice = () => Promise<ProviderChoice | null>;
65
-
66
63
  export type PreparedStreamExecution = {
67
64
  choice: ProviderChoice;
68
65
  started: StartedStream;
@@ -86,7 +83,6 @@ export async function executeNonStreaming(args: {
86
83
  interaction: InteractionRequest;
87
84
  primary: ProviderChoice;
88
85
  primaryRetries: number;
89
- resolveFallback: ResolveFallbackChoice;
90
86
  reporter: RoutedRuntimeReporter;
91
87
  protocol: RoutedProtocol;
92
88
  startedCompletion?: StartedCompletion;
@@ -111,7 +107,6 @@ export async function completeNonStreaming(args: {
111
107
  interaction: InteractionRequest;
112
108
  primary: ProviderChoice;
113
109
  primaryRetries: number;
114
- resolveFallback: ResolveFallbackChoice;
115
110
  reporter: RoutedRuntimeReporter;
116
111
  protocol: RoutedProtocol;
117
112
  startedCompletion?: StartedCompletion;
@@ -157,7 +152,7 @@ export async function completeNonStreaming(args: {
157
152
  output,
158
153
  modelCost: args.reporter.modelCost({
159
154
  model: choice.decision.selectedModel, reasoning_effort: choice.decision.reasoningEffort,
160
- role: choice.role, attempt: result.attempt, outcome: "completed", billingOutcome: "completed",
155
+ attempt: result.attempt, outcome: "completed", billingOutcome: "completed",
161
156
  execution: reportedExecution(choice.execution), message: completion.message,
162
157
  }),
163
158
  request: args.interaction,
@@ -231,7 +226,6 @@ async function completeWithRetries(args: {
231
226
  interaction: InteractionRequest;
232
227
  primary: ProviderChoice;
233
228
  primaryRetries: number;
234
- resolveFallback: ResolveFallbackChoice;
235
229
  reporter: RoutedRuntimeReporter;
236
230
  startedCompletion?: StartedCompletion;
237
231
  clientSignal?: AbortSignal;
@@ -239,7 +233,6 @@ async function completeWithRetries(args: {
239
233
  return runProviderAttemptPlan({
240
234
  primary: args.primary,
241
235
  retries: args.primaryRetries,
242
- resolveFallback: args.resolveFallback,
243
236
  primaryInitial: args.startedCompletion,
244
237
  isCustomerCanceled: () => customerAbortRequested(args.clientSignal),
245
238
  canceledFailure: customerCanceledProviderMessage,
@@ -459,7 +452,6 @@ export async function prepareStreamingExecution(args: {
459
452
  interaction: InteractionRequest;
460
453
  primary: ProviderChoice;
461
454
  primaryRetries: number;
462
- resolveFallback: ResolveFallbackChoice;
463
455
  reporter: RoutedRuntimeReporter;
464
456
  startedStream?: StartedStream;
465
457
  initialError?: unknown;
@@ -475,7 +467,6 @@ export async function prepareStreamingExecution(args: {
475
467
  const result = await runProviderAttemptPlan({
476
468
  primary: args.primary,
477
469
  retries: args.primaryRetries,
478
- resolveFallback: args.resolveFallback,
479
470
  primaryInitial,
480
471
  isCustomerCanceled: () => customerAbortRequested(args.clientSignal),
481
472
  canceledFailure: customerCanceledProviderMessage,
@@ -537,7 +528,7 @@ async function runStreamSetupAttempt(args: {
537
528
  clientSignal: args.clientSignal,
538
529
  });
539
530
  // Prime before committing so connection failures and prompt rejections
540
- // stay inside the one shared retry/fallback state machine.
531
+ // stay inside the one shared retry state machine.
541
532
  const primed = await primeProviderStream(
542
533
  started.providerStream.events,
543
534
  started.customerAbort,
@@ -611,7 +602,6 @@ export async function executeStreaming(args: {
611
602
  protocol: args.protocol,
612
603
  providerLatencyMs: () =>
613
604
  args.failedSetupLatencyMs + Date.now() - started.providerStartedMs,
614
- role: args.choice.role,
615
605
  attempt: args.attempt,
616
606
  reportBarrier: args.reportBarrier,
617
607
  delivered: observed.delivered,
@@ -630,7 +620,7 @@ export async function executeStreaming(args: {
630
620
  clientSignal: args.clientSignal,
631
621
  modelCost: (message, failed) => args.reporter.modelCost({
632
622
  model: args.choice.decision.selectedModel, reasoning_effort: args.choice.decision.reasoningEffort,
633
- role: args.choice.role, attempt: args.attempt,
623
+ attempt: args.attempt,
634
624
  outcome: failed ? "terminal_error" : "completed",
635
625
  billingOutcome: failed ? "provider_failed" : "completed",
636
626
  execution: reportedExecution(execution), message,
@@ -673,7 +663,6 @@ function recordProviderAttempt(
673
663
  reporter.recordProviderAttempt({
674
664
  model: choice.decision.selectedModel,
675
665
  reasoning_effort: choice.decision.reasoningEffort,
676
- role: choice.role,
677
666
  attempt,
678
667
  outcome,
679
668
  execution: reportedExecution(choice.execution),
@@ -720,7 +709,8 @@ async function providerFailedAttempt(
720
709
  }
721
710
  // Retrying an exhausted plan cannot succeed. The platform learns about it
722
711
  // before this request returns so the next resolve already serves the
723
- // user's next subscription, or the router's subscription fallback model.
712
+ // user's next subscription, or the model's API credential when the router
713
+ // allows subscription API fallback for it.
724
714
  const switched = execution.subscriptionUserId !== undefined
725
715
  && await holdSubscription({
726
716
  userId: execution.subscriptionUserId,
@@ -411,7 +411,7 @@ function reasoningText(value: unknown, type: "summary_text" | "reasoning_text"):
411
411
  );
412
412
  }
413
413
 
414
- function textSignature(value: unknown): { id?: string; phase?: "commentary" | "final_answer" } | null {
414
+ export function textSignature(value: unknown): { id?: string; phase?: "commentary" | "final_answer" } | null {
415
415
  if (typeof value !== "string" || !value) return null;
416
416
  try {
417
417
  const parsed: unknown = JSON.parse(value);
@@ -31,7 +31,6 @@ export type ProviderAttemptPlanResult<TChoice, TValue> = {
31
31
  type RunProviderAttemptPlanArgs<TChoice, TValue, TInitial> = {
32
32
  primary: TChoice;
33
33
  retries: number;
34
- resolveFallback: () => Promise<TChoice | null>;
35
34
  primaryInitial?: TInitial;
36
35
  isCustomerCanceled: (choice: TChoice) => boolean;
37
36
  canceledFailure: () => Record<string, unknown>;
@@ -51,8 +50,8 @@ type ChoiceResult<TChoice, TValue> = ProviderAttemptPlanResult<TChoice, TValue>;
51
50
 
52
51
  /**
53
52
  * Owns the order-sensitive provider plan shared by completion and stream setup:
54
- * retries within one choice, cancellation fences around asynchronous fallback
55
- * resolution, and one contiguous attempt record for every failed dispatch.
53
+ * retries within the choice, cancellation fences between attempts, and one
54
+ * contiguous attempt record for every failed dispatch.
56
55
  */
57
56
  export async function runProviderAttemptPlan<TChoice, TValue, TInitial>(
58
57
  args: RunProviderAttemptPlanArgs<TChoice, TValue, TInitial>,
@@ -62,29 +61,19 @@ export async function runProviderAttemptPlan<TChoice, TValue, TInitial>(
62
61
  }
63
62
  const primary = await runChoice(args, args.primary, args.primaryInitial);
64
63
  if (primary.kind !== "provider_failed") return primary;
65
- // The user's next subscription serves a retry of this same choice, which
66
- // beats a fallback model billed elsewhere.
64
+ // The user's next subscription serves the client's retry of this choice, so
65
+ // the failure stands even if the client has already gone.
67
66
  if (subscriptionUsageLimitFromFailure(primary.failure)?.switched === true) return primary;
68
- // A disconnect before or during fallback resolution stays attributed to the
69
- // primary: it is the only choice that was dispatched, and a resolved but
70
- // undispatched fallback must not appear as the serving model.
71
- const canceledAfterPrimary = () =>
72
- syntheticCancellation(args, {
67
+ // A client that disconnected while the plan settled is a cancellation,
68
+ // attributed to the only choice that was dispatched.
69
+ if (args.isCustomerCanceled(args.primary)) {
70
+ return syntheticCancellation(args, {
73
71
  choice: args.primary,
74
72
  attempt: primary.attempt + 1,
75
73
  latencyMs: primary.latencyMs,
76
74
  });
77
- if (args.isCustomerCanceled(args.primary)) return canceledAfterPrimary();
78
-
79
- const fallback = await args.resolveFallback();
80
- if (args.isCustomerCanceled(args.primary)) return canceledAfterPrimary();
81
- if (fallback === null) return primary;
82
-
83
- const fallbackResult = await runChoice(args, fallback, undefined);
84
- return {
85
- ...fallbackResult,
86
- latencyMs: primary.latencyMs + fallbackResult.latencyMs,
87
- };
75
+ }
76
+ return primary;
88
77
  }
89
78
 
90
79
  async function runChoice<TChoice, TValue, TInitial>(
@@ -33,6 +33,7 @@ import type {
33
33
  ProviderKeySource,
34
34
  ResolvedRouter,
35
35
  ResolvedRouterModelConfig,
36
+ ServedDecision,
36
37
  } from "../types.js";
37
38
  import { awsRoleCredentials } from "./aws_assume_role.js";
38
39
  import {
@@ -97,7 +98,7 @@ export type StartedStream = {
97
98
  };
98
99
 
99
100
  export function prepareProviderExecution(args: {
100
- decision: RoutingDecision;
101
+ decision: ServedDecision;
101
102
  credentials: ResolvedRouter;
102
103
  billable: boolean;
103
104
  routerId: string;
@@ -174,6 +175,7 @@ export function prepareProviderExecution(args: {
174
175
  ...(args.decision.leaseTurnsRemaining === undefined
175
176
  ? {}
176
177
  : { "X-Dari-Lease-Turns-Remaining": String(args.decision.leaseTurnsRemaining) }),
178
+ ...(args.decision.sessionPinned === true ? { "X-Dari-Session-Pinned": "true" } : {}),
177
179
  },
178
180
  };
179
181
  }
@@ -50,7 +50,6 @@ type CreateStreamFinalizerArgs = {
50
50
  reporter: RoutedRuntimeReporter;
51
51
  protocol: RoutedProtocol;
52
52
  providerLatencyMs: () => number;
53
- role: "primary" | "fallback";
54
53
  attempt: number;
55
54
  reportBarrier?: Promise<unknown>;
56
55
  delivered: () => DeliveredOutput;
@@ -171,7 +170,6 @@ function buildStreamTerminalReport(
171
170
  const attemptBase = {
172
171
  model: args.decision.selectedModel,
173
172
  reasoning_effort: args.decision.reasoningEffort,
174
- role: args.role,
175
173
  attempt: args.attempt,
176
174
  execution,
177
175
  } as const;
@@ -11,7 +11,7 @@ export type SubscriptionUsageLimit = {
11
11
  resetsAt: Date;
12
12
  reason: string;
13
13
  // The platform made the user's next subscription active, so a retry
14
- // succeeds without any fallback.
14
+ // succeeds on it.
15
15
  switched?: boolean;
16
16
  };
17
17
 
@@ -9,7 +9,6 @@ import { isBackgroundRequest } from "../../interaction/types.js";
9
9
  import { HttpError } from "../../http/errors.js";
10
10
  import { assertCreditAvailableBeforeRouting } from "../../telemetry/usage.js";
11
11
  import type { PreparedRoutedCompletion } from "../prepare_routed_completion.js";
12
- import { leaseFallbackDecision } from "../routing_lease.js";
13
12
 
14
13
  // Explicit background classes bypass the selector and every lease path.
15
14
  // Keep the existing deterministic cheapest-input-cost serving policy.
@@ -30,13 +29,7 @@ export function auxiliaryDecision(
30
29
  reasoningEffort: cheapest.reasoning_effort,
31
30
  reason: `${requestClass} request served by the cheapest routable model without selection.`,
32
31
  };
33
- const fallbackDecision = leaseFallbackDecision({
34
- candidates: prepared.routing.candidateResolution.candidates,
35
- decision,
36
- credentials: prepared.credentials,
37
- });
38
- if (fallbackDecision) prepared.reporter.attachFallbackDecision(fallbackDecision);
39
- return fallbackDecision ? { decision, fallbackDecision } : { decision };
32
+ return { decision };
40
33
  }
41
34
 
42
35
  /* Ranked on estimated input cost: these requests carry a whole transcript in
@@ -6,7 +6,7 @@ import type { RoutedDecision } from "../strategies/route_decision.js";
6
6
  import type { PreparedRoutedCompletion } from "./prepare_routed_completion.js";
7
7
 
8
8
  /* A request for `dari/model/<id>` is served by exactly that enabled model,
9
- with no selector, lease, or fallback: the user chose it, like picking a
9
+ with no selector or lease: the user chose it, like picking a
10
10
  model in their agent's /model menu. The client's thinking level is kept
11
11
  when the model supports it; otherwise the model's default level is used. */
12
12
  export function explicitModelDecision(
@@ -19,6 +19,11 @@ export type HarnessConversationIdentity =
19
19
  kind: "openai_chat_session";
20
20
  value: string;
21
21
  qualifiers?: string[];
22
+ }
23
+ | {
24
+ kind: "muse_code_thread";
25
+ value: string;
26
+ qualifiers?: string[];
22
27
  };
23
28
 
24
29
  export type HarnessConversationScope = {
@@ -46,6 +51,17 @@ export function scopedHarnessSessionId(args: {
46
51
  return scopedHarnessId({ ...args, identity });
47
52
  }
48
53
 
54
+ // One agent of a session: the main agent on the bare session, each subagent
55
+ // on its own qualifier or thread. Unlike the conversation id, it survives
56
+ // compaction.
57
+ export function scopedHarnessAgentId(args: {
58
+ identity: HarnessConversationIdentity;
59
+ sourceProtocol: SourceProtocol;
60
+ scope: HarnessConversationScope;
61
+ }): string {
62
+ return scopedHarnessId(args);
63
+ }
64
+
49
65
  export function scopedHarnessConversationId(args: {
50
66
  identity: HarnessConversationIdentity;
51
67
  sourceProtocol: SourceProtocol;
@@ -14,11 +14,10 @@ export function chatCompletionsConversationIdentity(
14
14
  headers: Headers,
15
15
  promptCacheKey?: string,
16
16
  ): HarnessConversationIdentity | undefined {
17
- // x-session-affinity is the authoritative Chat Completions identity.
18
- // Fall back to prompt_cache_key from the request body for callers that
19
- // send a stable cache key without the affinity header, so append-only
20
- // sessions (growing message history) stay grouped under one conversation.
21
- const sessionId = boundedIdentity(
17
+ // Pi summaries use fresh cache/affinity IDs. Its explicit session header
18
+ // groups them with the main turns without changing provider cache affinity.
19
+ // Other callers use x-session-affinity, falling back to prompt_cache_key.
20
+ const sessionId = boundedIdentity(headers.get("x-pi-session-id"), "x-pi-session-id") ?? boundedIdentity(
22
21
  headers.get("x-session-affinity"),
23
22
  "x-session-affinity",
24
23
  ) ?? boundedIdentity(promptCacheKey, "prompt_cache_key");
@@ -34,6 +33,16 @@ export function chatCompletionsConversationIdentity(
34
33
  };
35
34
  }
36
35
 
36
+ export function piRequestContext(headers: Headers): AgentRequestContext | undefined {
37
+ if (boundedIdentity(headers.get("x-pi-session-id"), "x-pi-session-id") === undefined) {
38
+ return undefined;
39
+ }
40
+ const kind = headers.get("x-pi-request-kind")?.trim();
41
+ return {
42
+ requestClass: kind === "turn" ? "main" : kind === "compaction" ? "compaction" : "unknown",
43
+ };
44
+ }
45
+
37
46
  // OpenCode names the session behind every request, and a subagent's parent
38
47
  // session too. The `dari opencode` plugin adds the request kind and agent,
39
48
  // which OpenCode does not send itself. Like Claude Code's headers, these are
@@ -174,6 +183,16 @@ export function codexRequestContext(
174
183
  };
175
184
  }
176
185
 
186
+ // Muse Code sends its thread on every Responses request. The main agent's
187
+ // thread is its session, and each subagent has its own thread, so each keeps
188
+ // its own conversation and lease.
189
+ export function museCodeConversationIdentity(
190
+ headers: Headers,
191
+ ): HarnessConversationIdentity | undefined {
192
+ const threadId = boundedIdentity(headers.get("x-tbh-thread-id"), "x-tbh-thread-id");
193
+ return threadId === undefined ? undefined : { kind: "muse_code_thread", value: threadId };
194
+ }
195
+
177
196
  export function claudeCodeConversationIdentity(
178
197
  headers: Headers,
179
198
  metadataSessionId?: string,
@@ -1,4 +1,5 @@
1
1
  export {
2
+ scopedHarnessAgentId,
2
3
  scopedHarnessConversationId,
3
4
  scopedHarnessSessionId,
4
5
  type HarnessConversationIdentity,
@@ -10,5 +11,7 @@ export {
10
11
  codexConversationIdentity,
11
12
  codexRequestPurpose,
12
13
  codexRequestContext,
14
+ museCodeConversationIdentity,
13
15
  openCodeRequestContext,
16
+ piRequestContext,
14
17
  } from "./extract.js";
@@ -10,7 +10,8 @@ import {
10
10
  import { isBackgroundRequest, type InteractionRequest } from "../interaction/types.js";
11
11
  import { executionProviderForModel } from "../models/execution_provider.js";
12
12
  import { codexModelConfig, subscriptionServes } from "../models/subscriptions.js";
13
- import { gatewayPlatform, type RequestTags } from "../platform/platform.js";
13
+ import { explicitModelId } from "../protocol/input/router_model.js";
14
+ import { gatewayPlatform, type RequestTags, type SessionPin } from "../platform/platform.js";
14
15
  import { resolveRouter } from "../strategies/routing.js";
15
16
  import { RequestReporter, type ReportedCandidatePruning } from "../telemetry/report.js";
16
17
  import { RoutingBillingFinalization } from "../telemetry/billing_finalization.js";
@@ -20,6 +21,7 @@ import { strictProviderAccess } from "./strict_provider_access.js";
20
21
  import type { RoutedRuntimeReporter } from "./runtime_reporter.js";
21
22
  import type { RoutedProtocol } from "./routed_protocol.js";
22
23
  import {
24
+ scopedHarnessAgentId,
23
25
  scopedHarnessConversationId,
24
26
  scopedHarnessSessionId,
25
27
  type HarnessConversationIdentity,
@@ -36,6 +38,9 @@ export type PreparedRoutedCompletion = {
36
38
  tags: RequestTags;
37
39
  harnessConversationIdentity?: HarnessConversationIdentity;
38
40
  routing: RoutingContext;
41
+ // Present when this request is served by session pinning: the session's
42
+ // pin key and the pair it pinned, null before its first routed turn.
43
+ sessionPin?: { key: string; pin: SessionPin | null };
39
44
  reporter: RoutedRuntimeReporter;
40
45
  };
41
46
 
@@ -140,6 +145,30 @@ export async function prepareRoutedCompletion(args: {
140
145
  receivedAt: args.receivedAt,
141
146
  });
142
147
  const candidateResolution = routing.candidateResolution;
148
+ let sessionPin: PreparedRoutedCompletion["sessionPin"];
149
+ if (
150
+ credentials.session_pinning === true &&
151
+ credentials.strict !== true &&
152
+ !background &&
153
+ explicitModelId(args.interaction.model) === null
154
+ ) {
155
+ // Harness identity keys the main agent and each subagent separately and
156
+ // survives compaction. Claude Code names its subagents only with gateway
157
+ // hint headers; without them, and for callers with no harness identity,
158
+ // each conversation is pinned on its own.
159
+ const identity = args.harnessConversationIdentity;
160
+ const unclassifiedClaudeCode = identity?.kind === "claude_code_session"
161
+ && args.interaction.agentContext?.requestClass === "unknown";
162
+ const key = identity === undefined || unclassifiedClaudeCode
163
+ ? routing.conversationId
164
+ : scopedHarnessAgentId({
165
+ identity,
166
+ sourceProtocol: args.interaction.sourceProtocol,
167
+ scope: harnessScope,
168
+ });
169
+ const pin = await gatewayPlatform().readSessionPin({ routerId: canonicalRouterId, pinKey: key });
170
+ sessionPin = { key, pin };
171
+ }
143
172
  const reporter = new RequestReporter({
144
173
  requestId: args.requestId,
145
174
  clientRequestId: args.clientRequestId,
@@ -184,12 +213,13 @@ export async function prepareRoutedCompletion(args: {
184
213
  tags,
185
214
  harnessConversationIdentity: args.harnessConversationIdentity,
186
215
  routing,
216
+ ...(sessionPin === undefined ? {} : { sessionPin }),
187
217
  reporter,
188
218
  };
189
219
  }
190
220
 
191
221
  // Ask the platform for each enabled model's credential up front, so provider
192
- // execution and fallback stay synchronous.
222
+ // execution stays synchronous.
193
223
  async function withProviderCredentials(
194
224
  credentials: ResolvedRouter,
195
225
  apiKey: string,
@@ -39,7 +39,6 @@ import { auxiliaryDecision } from "./claude_code/auxiliary_routing.js";
39
39
  import { explicitModelDecision, strictModelDecision } from "./explicit_model.js";
40
40
  import {
41
41
  clearPendingLease,
42
- committedLeaseDecision,
43
42
  leaseHoldDecision,
44
43
  leasePrefetchDue,
45
44
  leasedDecision,
@@ -47,6 +46,7 @@ import {
47
46
  prefetchNextLease,
48
47
  selectorNegotiatesLeases,
49
48
  } from "./routing_lease.js";
49
+ import { pinSession, sessionPinHoldDecision, sessionPinnedSelection } from "./session_pin.js";
50
50
  import type { RoutedProtocol } from "./routed_protocol.js";
51
51
  import {
52
52
  createExperimentRoutedRuntimeReporter,
@@ -69,7 +69,6 @@ type SpeculativeExecution = {
69
69
  type RuntimeExecutionPlan = {
70
70
  primary: ProviderChoice;
71
71
  primaryRetries: number;
72
- resolveFallback: () => Promise<ProviderChoice | null>;
73
72
  reportBarrier?: Promise<unknown>;
74
73
  startedCompletion?: StartedCompletion;
75
74
  startedStream?: StartedStream;
@@ -137,7 +136,6 @@ export async function runPreparedRoutedCompletion(args: {
137
136
  interaction: prepared.interaction,
138
137
  primary: plan.primary,
139
138
  primaryRetries: plan.primaryRetries,
140
- resolveFallback: plan.resolveFallback,
141
139
  reporter: prepared.reporter,
142
140
  protocol: prepared.protocol,
143
141
  startedCompletion: plan.startedCompletion,
@@ -150,7 +148,6 @@ export async function runPreparedRoutedCompletion(args: {
150
148
  interaction: prepared.interaction,
151
149
  primary: plan.primary,
152
150
  primaryRetries: plan.primaryRetries,
153
- resolveFallback: plan.resolveFallback,
154
151
  reporter: prepared.reporter,
155
152
  startedStream: plan.startedStream,
156
153
  initialError: plan.initialStreamError,
@@ -191,7 +188,6 @@ export async function runPreparedRoutedInteraction(args: {
191
188
  interaction: args.prepared.interaction,
192
189
  primary: plan.primary,
193
190
  primaryRetries: plan.primaryRetries,
194
- resolveFallback: plan.resolveFallback,
195
191
  reporter: args.prepared.reporter,
196
192
  protocol: args.prepared.protocol,
197
193
  startedCompletion: plan.startedCompletion,
@@ -253,8 +249,7 @@ async function prepareRuntimeExecution(args: {
253
249
  }): Promise<RuntimeExecutionPlan> {
254
250
  const { prepared } = args;
255
251
 
256
- // A strict router never selects, leases, or falls back: the pinned pair or
257
- // an error.
252
+ // A strict router never selects or leases: the pinned pair or an error.
258
253
  if (prepared.credentials.strict === true) {
259
254
  const pinned = strictModelDecision(prepared);
260
255
  prepared.reporter.attachServingDecision(pinned.decision);
@@ -298,14 +293,20 @@ async function prepareRuntimeExecution(args: {
298
293
  return selectedExecutionPlan(prepared, auxiliary);
299
294
  }
300
295
 
296
+ // A session-pinning router serves the session's pin, or selects once and
297
+ // pins the result. Leases and speculation never apply to a pinned session.
298
+ if (prepared.sessionPin !== undefined && !args.requireSelectorDecision) {
299
+ return selectedExecutionPlan(
300
+ prepared,
301
+ await sessionPinnedDecision(prepared, args.selectorBackend),
302
+ );
303
+ }
304
+
301
305
  // Persisted policy leases (negotiated {action, turns}) take precedence.
302
306
  // Explicit in-process runs are selector experiments: they must observe the
303
307
  // selector and must not consume or prefetch live conversations' leases.
304
308
  const held = args.requireSelectorDecision ? null : leaseHoldDecision(prepared);
305
309
  if (held) {
306
- // Serving-only attach: nextDecision stays unset so if a fallback serves
307
- // this turn, execution's attachServingDecision moves both the prefix's
308
- // next_model and inherited countdown onto the healthy serving pair.
309
310
  prepared.reporter.attachServingDecision(held);
310
311
  prepared.reporter.attachDecisionDetails({
311
312
  decision_source: "lease_hold",
@@ -324,7 +325,7 @@ async function prepareRuntimeExecution(args: {
324
325
  if (leasePrefetchDue(prepared)) {
325
326
  trackBackgroundTelemetry(prefetchNextLease(prepared, args.selectorBackend));
326
327
  }
327
- return selectedExecutionPlan(prepared, committedLeaseDecision(prepared, held));
328
+ return selectedExecutionPlan(prepared, { decision: held });
328
329
  }
329
330
  const adopted = args.requireSelectorDecision ? null : pendingLeaseDecision(prepared);
330
331
  if (adopted) {
@@ -349,7 +350,7 @@ async function prepareRuntimeExecution(args: {
349
350
  // wait elapses, the next lease's own prefetch overwrites the stale rows
350
351
  // before its expiry (the menu's shortest lease exceeds the lag).
351
352
  await clearPendingLease(prepared);
352
- return selectedExecutionPlan(prepared, committedLeaseDecision(prepared, adopted));
353
+ return selectedExecutionPlan(prepared, { decision: adopted });
353
354
  }
354
355
 
355
356
  // The stateless ten-turn hold covers per-turn policies. A lease-negotiating
@@ -380,7 +381,6 @@ async function prepareRuntimeExecution(args: {
380
381
  const primary: ProviderChoice = {
381
382
  decision: speculative.decision,
382
383
  execution: speculative.execution,
383
- role: "primary",
384
384
  };
385
385
  const selectionPromise = selectDecision(
386
386
  prepared,
@@ -390,23 +390,13 @@ async function prepareRuntimeExecution(args: {
390
390
  );
391
391
  // The execution layer awaits selection as its report barrier only after the
392
392
  // provider settles, which can be long after a rejection. Mark the promise
393
- // handled now; fallback still consumes the original and its rejection.
393
+ // handled now; the report barrier still observes the original rejection.
394
394
  selectionPromise.catch(() => undefined);
395
- let fallbackPromise: Promise<ProviderChoice | null> | null = null;
396
- const resolveFallback = (): Promise<ProviderChoice | null> => {
397
- fallbackPromise ??= selectionPromise.then((selected) => {
398
- const decision = fallbackForPrimary(prepared, primary.decision, selected);
399
- if (decision) prepared.reporter.attachFallbackDecision(decision);
400
- return decision ? providerChoice(prepared, decision, "fallback") : null;
401
- });
402
- return fallbackPromise;
403
- };
404
395
  return {
405
396
  primary,
406
397
  primaryRetries: configuredPrimaryRetries(
407
398
  prepared.credentials.primary_retries,
408
399
  ),
409
- resolveFallback,
410
400
  reportBarrier: selectionPromise,
411
401
  startedCompletion:
412
402
  speculative.transport === "completion" ? speculative.started : undefined,
@@ -424,20 +414,10 @@ function selectedExecutionPlan(
424
414
  selected: RoutedDecision,
425
415
  ): RuntimeExecutionPlan {
426
416
  return {
427
- primary: providerChoice(prepared, selected.decision, "primary"),
417
+ primary: providerChoice(prepared, selected.decision),
428
418
  primaryRetries: configuredPrimaryRetries(
429
419
  prepared.credentials.primary_retries,
430
420
  ),
431
- resolveFallback: async () => {
432
- if (!selected.fallbackDecision) return null;
433
- const fallbackDecision = selected.decision.leaseTurnsRemaining === undefined
434
- ? selected.fallbackDecision
435
- : {
436
- ...selected.fallbackDecision,
437
- leaseTurnsRemaining: selected.decision.leaseTurnsRemaining,
438
- };
439
- return providerChoice(prepared, fallbackDecision, "fallback");
440
- },
441
421
  };
442
422
  }
443
423
 
@@ -503,6 +483,28 @@ async function startSpeculativeExecution(
503
483
  }
504
484
  }
505
485
 
486
+ async function sessionPinnedDecision(
487
+ prepared: PreparedRoutedCompletion,
488
+ selectorBackend: SelectorBackend,
489
+ ): Promise<RoutedDecision> {
490
+ const held = sessionPinHoldDecision(prepared);
491
+ if (held) {
492
+ prepared.reporter.attachDecision(held);
493
+ prepared.reporter.attachDecisionDetails({
494
+ decision_source: "session_pin",
495
+ session_pin: { model: held.selectedModel, reasoning_effort: held.reasoningEffort },
496
+ });
497
+ return { decision: held };
498
+ }
499
+ const selected = await selectDecision(prepared, null, selectorBackend);
500
+ prepared.reporter.attachDecisionDetails({
501
+ decision_source: "selector",
502
+ session_pin_established: true,
503
+ });
504
+ await pinSession(prepared, selected.decision);
505
+ return selected;
506
+ }
507
+
506
508
  async function selectDecision(
507
509
  prepared: PreparedRoutedCompletion,
508
510
  servingDecision: RoutingDecision | null,
@@ -511,7 +513,7 @@ async function selectDecision(
511
513
  ): Promise<RoutedDecision> {
512
514
  const { credentials, reporter, routing } = prepared;
513
515
  try {
514
- const routed = await routeDecision({
516
+ const selected = await routeDecision({
515
517
  selectorMessages: routing.selectorMessages,
516
518
  selectorImages: routing.selectorImages,
517
519
  resolution: routing.candidateResolution,
@@ -521,6 +523,7 @@ async function selectDecision(
521
523
  selectorBackend,
522
524
  leaseHistory: routing.leaseHistory,
523
525
  });
526
+ const routed = prepared.sessionPin === undefined ? selected : sessionPinnedSelection(selected);
524
527
  if (routed.details) reporter.attachDecisionDetails(routed.details);
525
528
  if (servingDecision) {
526
529
  if (!routed.run) {
@@ -544,7 +547,6 @@ async function selectDecision(
544
547
  reporter.attachSelectorRun(routed.run);
545
548
  } else {
546
549
  reporter.attachDecision(routed.decision);
547
- reporter.attachFallbackDecision(routed.fallbackDecision);
548
550
  }
549
551
  return routed;
550
552
  } catch (error) {
@@ -592,35 +594,13 @@ function configuredPrimaryRetries(value: number | undefined): number {
592
594
  return Number.isInteger(retries) && retries >= 0 && retries <= 5 ? retries : 1;
593
595
  }
594
596
 
595
- function fallbackForPrimary(
596
- prepared: PreparedRoutedCompletion,
597
- primary: RoutingDecision,
598
- selected: RoutedDecision,
599
- ): RoutingDecision | undefined {
600
- if (prepared.credentials.model_fallback_enabled === false) return undefined;
601
- const fallback = [selected.fallbackDecision, selected.decision].find(
602
- (candidate): candidate is RoutingDecision =>
603
- candidate !== undefined &&
604
- candidate.selectedModel !== primary.selectedModel,
605
- );
606
- if (!fallback || selected.decision.leaseTurnsRemaining === undefined) {
607
- return fallback;
608
- }
609
- return {
610
- ...fallback,
611
- leaseTurnsRemaining: selected.decision.leaseTurnsRemaining,
612
- };
613
- }
614
-
615
597
  function providerChoice(
616
598
  prepared: PreparedRoutedCompletion,
617
599
  decision: RoutingDecision,
618
- role: ProviderChoice["role"],
619
600
  ): ProviderChoice {
620
601
  return {
621
602
  decision,
622
603
  execution: providerExecution(prepared, decision),
623
- role,
624
604
  };
625
605
  }
626
606