@mupt-ai/dari-gateway 0.3.1 → 0.3.2

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 (108) hide show
  1. package/README.md +14 -6
  2. package/dari-gateway.example.yaml +3 -2
  3. package/dist/http/auth.js +2 -2
  4. package/dist/http/http2_server.d.ts +1 -1
  5. package/dist/http/http2_server.js +3 -3
  6. package/dist/http/serve.d.ts +1 -0
  7. package/dist/http/serve.js +5 -4
  8. package/dist/ingress/chat_completions.js +2 -2
  9. package/dist/ingress/codex.js +3 -1
  10. package/dist/ingress/codex_models.d.ts +1 -1
  11. package/dist/ingress/codex_models.js +22 -13
  12. package/dist/platform/platform.d.ts +13 -3
  13. package/dist/protocol/input/openai_responses.js +2 -4
  14. package/dist/protocol/output/anthropic_messages_output.d.ts +2 -2
  15. package/dist/protocol/output/anthropic_messages_output.js +1 -0
  16. package/dist/protocol/output/openai_responses_stream.js +19 -1
  17. package/dist/providers/execution.d.ts +0 -5
  18. package/dist/providers/execution.js +5 -8
  19. package/dist/providers/pi_output.d.ts +4 -0
  20. package/dist/providers/pi_output.js +1 -1
  21. package/dist/providers/provider_attempt_runner.d.ts +2 -3
  22. package/dist/providers/provider_attempt_runner.js +14 -24
  23. package/dist/providers/provider_call.d.ts +3 -3
  24. package/dist/providers/provider_call.js +1 -0
  25. package/dist/providers/stream_finalization.d.ts +0 -1
  26. package/dist/providers/stream_finalization.js +0 -1
  27. package/dist/runtime/claude_code/auxiliary_routing.js +1 -9
  28. package/dist/runtime/explicit_model.js +1 -1
  29. package/dist/runtime/harness_identity/conversation.d.ts +5 -0
  30. package/dist/runtime/harness_identity/conversation.js +6 -0
  31. package/dist/runtime/harness_identity/extract.d.ts +1 -0
  32. package/dist/runtime/harness_identity/extract.js +13 -5
  33. package/dist/runtime/harness_identity/index.d.ts +2 -2
  34. package/dist/runtime/harness_identity/index.js +2 -2
  35. package/dist/runtime/prepare_routed_completion.d.ts +5 -1
  36. package/dist/runtime/prepare_routed_completion.js +26 -2
  37. package/dist/runtime/routed_runtime.js +33 -53
  38. package/dist/runtime/routing_lease.d.ts +4 -6
  39. package/dist/runtime/routing_lease.js +2 -47
  40. package/dist/runtime/runtime_reporter.d.ts +0 -2
  41. package/dist/runtime/runtime_reporter.js +0 -1
  42. package/dist/runtime/session_pin.d.ts +7 -0
  43. package/dist/runtime/session_pin.js +51 -0
  44. package/dist/self_host/config.d.ts +2 -3
  45. package/dist/self_host/config.js +1 -2
  46. package/dist/self_host/main.d.ts +2 -1
  47. package/dist/self_host/main.js +20 -4
  48. package/dist/self_host/platform.d.ts +4 -2
  49. package/dist/self_host/platform.js +6 -2
  50. package/dist/self_host/resolve.d.ts +8 -1
  51. package/dist/self_host/resolve.js +31 -8
  52. package/dist/self_host/session_pin_store.d.ts +14 -0
  53. package/dist/self_host/session_pin_store.js +44 -0
  54. package/dist/strategies/priority_selector.d.ts +1 -2
  55. package/dist/strategies/priority_selector.js +1 -2
  56. package/dist/strategies/route_decision.d.ts +0 -1
  57. package/dist/strategies/route_decision.js +0 -3
  58. package/dist/strategies/routing.d.ts +0 -3
  59. package/dist/strategies/routing.js +1 -14
  60. package/dist/telemetry/billing_finalization.d.ts +1 -2
  61. package/dist/telemetry/billing_finalization.js +1 -1
  62. package/dist/telemetry/prefix_state.d.ts +0 -1
  63. package/dist/telemetry/prefix_state.js +1 -3
  64. package/dist/telemetry/report.d.ts +0 -2
  65. package/dist/telemetry/report.js +5 -41
  66. package/dist/types.d.ts +5 -3
  67. package/package.json +2 -2
  68. package/src/http/auth.ts +2 -2
  69. package/src/http/http2_server.ts +7 -3
  70. package/src/http/serve.ts +6 -5
  71. package/src/ingress/chat_completions.ts +2 -1
  72. package/src/ingress/codex.ts +4 -1
  73. package/src/ingress/codex_models.ts +18 -7
  74. package/src/platform/platform.ts +16 -2
  75. package/src/protocol/input/openai_responses.ts +2 -9
  76. package/src/protocol/output/anthropic_messages_output.ts +3 -2
  77. package/src/protocol/output/openai_responses_stream.ts +17 -1
  78. package/src/providers/execution.ts +5 -15
  79. package/src/providers/pi_output.ts +1 -1
  80. package/src/providers/provider_attempt_runner.ts +10 -21
  81. package/src/providers/provider_call.ts +3 -1
  82. package/src/providers/stream_finalization.ts +0 -2
  83. package/src/providers/subscription_limits.ts +1 -1
  84. package/src/runtime/claude_code/auxiliary_routing.ts +1 -8
  85. package/src/runtime/explicit_model.ts +1 -1
  86. package/src/runtime/harness_identity/conversation.ts +11 -0
  87. package/src/runtime/harness_identity/extract.ts +14 -5
  88. package/src/runtime/harness_identity/index.ts +2 -0
  89. package/src/runtime/prepare_routed_completion.ts +32 -2
  90. package/src/runtime/routed_runtime.ts +39 -59
  91. package/src/runtime/routing_lease.ts +2 -56
  92. package/src/runtime/runtime_reporter.ts +0 -3
  93. package/src/runtime/session_pin.ts +61 -0
  94. package/src/self_host/config.ts +3 -6
  95. package/src/self_host/main.ts +23 -4
  96. package/src/self_host/platform.ts +8 -2
  97. package/src/self_host/resolve.ts +37 -7
  98. package/src/self_host/session_pin_store.ts +58 -0
  99. package/src/strategies/priority_selector.ts +1 -2
  100. package/src/strategies/route_decision.ts +0 -5
  101. package/src/strategies/routing.ts +1 -17
  102. package/src/telemetry/billing_finalization.ts +4 -3
  103. package/src/telemetry/prefix_state.ts +1 -6
  104. package/src/telemetry/report.ts +4 -48
  105. package/src/types.ts +12 -6
  106. package/dist/strategies/fallback_decision.d.ts +0 -8
  107. package/dist/strategies/fallback_decision.js +0 -17
  108. package/src/strategies/fallback_decision.ts +0 -30
@@ -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
@@ -1,4 +1,5 @@
1
1
  export {
2
+ scopedHarnessAgentId,
2
3
  scopedHarnessConversationId,
3
4
  scopedHarnessSessionId,
4
5
  type HarnessConversationIdentity,
@@ -11,4 +12,5 @@ export {
11
12
  codexRequestPurpose,
12
13
  codexRequestContext,
13
14
  openCodeRequestContext,
15
+ piRequestContext,
14
16
  } 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
 
@@ -6,8 +6,6 @@ import {
6
6
  type RoutingDecision,
7
7
  } from "@mupt-ai/dari-router/policy-engine";
8
8
 
9
- import { deriveFallbackDecision } from "../strategies/fallback_decision.js";
10
- import { executionProviderForModel } from "../models/execution_provider.js";
11
9
  import type { SelectorBackend } from "../strategies/routing.js";
12
10
  import {
13
11
  routeDecision,
@@ -71,17 +69,7 @@ export function leasedDecision(
71
69
  // incumbent cannot hide an available user-owned key behind the lease.
72
70
  if (prepared.billable && prepared.credentials.credit_exhausted) return null;
73
71
 
74
- const fallbackDecision = leaseFallbackDecision({
75
- candidates: prepared.routing.candidateResolution.candidates,
76
- decision: lease.decision,
77
- credentials: prepared.credentials,
78
- });
79
- // Use attachServingDecision rather than attachDecision so next_model
80
- // follows the model that actually serves. When fallback handles a primary
81
- // failure, it inherits this countdown and subsequent held turns continue
82
- // on the healthy serving pair instead of retrying the failed primary.
83
72
  prepared.reporter.attachServingDecision(lease.decision);
84
- prepared.reporter.attachFallbackDecision(fallbackDecision);
85
73
  prepared.reporter.attachDecisionDetails({
86
74
  decision_source: "lease_hold",
87
75
  routing_lease: {
@@ -90,49 +78,7 @@ export function leasedDecision(
90
78
  turn_in_lease: lease.turnInLease,
91
79
  },
92
80
  });
93
- return { decision: lease.decision, fallbackDecision };
94
- }
95
-
96
- // Wraps a lease-held (or adopted) decision with a runtime-derived fallback so
97
- // a mid-lease provider failure degrades like any other turn instead of
98
- // erroring the request. The fallback inherits the remaining commitment so the
99
- // completed prefix follows the healthy serving pair for the rest of the lease.
100
- export function committedLeaseDecision(
101
- prepared: PreparedRoutedCompletion,
102
- decision: RoutingDecision,
103
- ): RoutedDecision {
104
- const fallbackDecision = leaseFallbackDecision({
105
- candidates: prepared.routing.candidateResolution.candidates,
106
- decision,
107
- credentials: prepared.credentials,
108
- });
109
- if (fallbackDecision) prepared.reporter.attachFallbackDecision(fallbackDecision);
110
- return fallbackDecision ? { decision, fallbackDecision } : { decision };
111
- }
112
-
113
- export function leaseFallbackDecision(args: {
114
- candidates: PreparedRoutedCompletion["routing"]["candidateResolution"]["candidates"];
115
- decision: RoutingDecision;
116
- credentials: PreparedRoutedCompletion["credentials"];
117
- }): RoutingDecision | undefined {
118
- const fallback = deriveFallbackDecision({
119
- candidates: args.candidates,
120
- selected: {
121
- model: args.decision.selectedModel,
122
- reasoningEffort: args.decision.reasoningEffort,
123
- },
124
- enabled: args.credentials.model_fallback_enabled !== false,
125
- requiresDifferentProvider:
126
- args.credentials.fallback_requires_different_provider !== false,
127
- providerForModel: (model) => executionProviderForModel(args.credentials, model),
128
- });
129
- if (!fallback || args.decision.leaseTurnsRemaining === undefined) {
130
- return fallback;
131
- }
132
- return {
133
- ...fallback,
134
- leaseTurnsRemaining: args.decision.leaseTurnsRemaining,
135
- };
81
+ return { decision: lease.decision };
136
82
  }
137
83
 
138
84
  export async function selectorNegotiatesLeases(
@@ -220,7 +166,7 @@ export async function clearPendingLease(
220
166
  );
221
167
  }
222
168
 
223
- function canServeCommittedPair(
169
+ export function canServeCommittedPair(
224
170
  prepared: PreparedRoutedCompletion,
225
171
  pair: { model: string; reasoningEffort: string },
226
172
  ): boolean {
@@ -46,7 +46,6 @@ export type ReportableProviderOutcome =
46
46
  export type ProviderAttemptReport = {
47
47
  model: string;
48
48
  reasoning_effort: string;
49
- role: "primary" | "fallback";
50
49
  attempt: number;
51
50
  outcome: "completed" | "error" | "terminal_error" | "canceled";
52
51
  };
@@ -65,7 +64,6 @@ export type RoutedRuntimeReporter = {
65
64
  attachSelectorRun(run: SelectorRun): void;
66
65
  attachSpeculativeSelectorRun(run: SelectorRun, servingDecision: RoutingDecision): void;
67
66
  attachDecision(decision: RoutingDecision): void;
68
- attachFallbackDecision(decision: RoutingDecision | undefined): void;
69
67
  attachServingDecision(decision: RoutingDecision): void;
70
68
  recordProviderAttempt(attempt: ProviderAttemptRecord): void;
71
69
  modelCost(attempt?: ProviderAttemptRecord): number | null;
@@ -94,7 +92,6 @@ export function createExperimentRoutedRuntimeReporter(): ExperimentRoutedRuntime
94
92
  attachSelectorRun() {},
95
93
  attachSpeculativeSelectorRun() {},
96
94
  attachDecision() {},
97
- attachFallbackDecision() {},
98
95
  attachServingDecision() {},
99
96
  recordProviderAttempt() {},
100
97
  modelCost() { return null; },
@@ -0,0 +1,61 @@
1
+ import type { RoutingDecision } from "@mupt-ai/dari-router/policy-engine";
2
+
3
+ import { gatewayPlatform } from "../platform/platform.js";
4
+ import type { RoutedDecision } from "../strategies/route_decision.js";
5
+ import type { ServedDecision } from "../types.js";
6
+ import type { PreparedRoutedCompletion } from "./prepare_routed_completion.js";
7
+ import { canServeCommittedPair } from "./routing_lease.js";
8
+
9
+ // The session's pinned pair, while this request may still serve it. A pin
10
+ // that can no longer serve (model disabled, credits exhausted, image input on
11
+ // a text-only model) falls through to selection, which moves the pin.
12
+ export function sessionPinHoldDecision(
13
+ prepared: PreparedRoutedCompletion,
14
+ ): ServedDecision | null {
15
+ const pin = prepared.sessionPin?.pin;
16
+ if (pin === null || pin === undefined) return null;
17
+ if (!canServeCommittedPair(prepared, pin)) return null;
18
+ return {
19
+ selectedModel: pin.model,
20
+ reasoningEffort: pin.reasoningEffort,
21
+ reason: `Session pin: ${pin.model}/${pin.reasoningEffort} serves this whole session.`,
22
+ sessionPinned: true,
23
+ };
24
+ }
25
+
26
+ // A pinned session never holds leases: the pin outlives any countdown.
27
+ export function sessionPinnedSelection(routed: RoutedDecision): RoutedDecision {
28
+ const pinned = (decision: RoutingDecision): ServedDecision => ({ ...unleased(decision), sessionPinned: true });
29
+ return {
30
+ ...routed,
31
+ decision: pinned(routed.decision),
32
+ ...(routed.run === undefined ? {} : { run: { ...routed.run, decision: pinned(routed.run.decision) } }),
33
+ };
34
+ }
35
+
36
+ function unleased(decision: RoutingDecision): RoutingDecision {
37
+ const { leaseTurnsRemaining: _, ...rest } = decision;
38
+ return rest;
39
+ }
40
+
41
+ // A failed write only costs the pin: the next turn selects again.
42
+ export async function pinSession(
43
+ prepared: PreparedRoutedCompletion,
44
+ decision: RoutingDecision,
45
+ ): Promise<void> {
46
+ if (prepared.sessionPin === undefined) return;
47
+ const pin = { model: decision.selectedModel, reasoningEffort: decision.reasoningEffort };
48
+ try {
49
+ await gatewayPlatform().writeSessionPin({
50
+ routerId: prepared.routerId,
51
+ pinKey: prepared.sessionPin.key,
52
+ ...pin,
53
+ });
54
+ } catch (error) {
55
+ gatewayPlatform().captureOperationalError(error, {
56
+ code: "session_pin_write_failed",
57
+ requestId: prepared.requestId,
58
+ routerId: prepared.routerId,
59
+ });
60
+ }
61
+ }
@@ -14,8 +14,8 @@ import {
14
14
  * - anthropic/claude-sonnet-5-5
15
15
  * - id: openai/gpt-5.5
16
16
  * thinking: [medium, high]
17
- * fallback: true # retry on the next model when a provider fails
18
17
  * subscriptions: true # serve with connected Claude Code / ChatGPT plans
18
+ * sessionPinning: false # route each session and subagent once, then keep its model
19
19
  * providers: # optional: OpenAI-compatible or self-run endpoints
20
20
  * local:
21
21
  * baseUrl: http://localhost:8000/v1
@@ -44,10 +44,9 @@ export type ProviderEndpointConfig = {
44
44
 
45
45
  export type RouterConfig = {
46
46
  models: RouterModelConfig[];
47
- fallback: boolean;
48
- fallbackRequiresDifferentProvider: boolean;
49
47
  retries: number;
50
48
  subscriptions: boolean;
49
+ sessionPinning: boolean;
51
50
  zdr: boolean;
52
51
  };
53
52
 
@@ -129,11 +128,9 @@ function parseRouter(id: string, raw: unknown): RouterConfig {
129
128
  }
130
129
  return {
131
130
  models,
132
- fallback: optionalBoolean(raw.fallback, `routers.${id}.fallback`) ?? true,
133
- fallbackRequiresDifferentProvider:
134
- optionalBoolean(raw.fallbackRequiresDifferentProvider, `routers.${id}.fallbackRequiresDifferentProvider`) ?? false,
135
131
  retries: optionalRetries(raw.retries, `routers.${id}.retries`) ?? 1,
136
132
  subscriptions: optionalBoolean(raw.subscriptions, `routers.${id}.subscriptions`) ?? true,
133
+ sessionPinning: optionalBoolean(raw.sessionPinning, `routers.${id}.sessionPinning`) ?? false,
137
134
  zdr: optionalBoolean(raw.zdr, `routers.${id}.zdr`) ?? false,
138
135
  };
139
136
  }
@@ -12,8 +12,9 @@ import { createPrioritySelectorBackend } from "../strategies/priority_selector.j
12
12
  import type { SelectorBackend } from "../strategies/routing.js";
13
13
  import { loadGatewayConfig } from "./config.js";
14
14
  import { createSelfHostedPlatform } from "./platform.js";
15
- import { gatewayKeyIndex } from "./resolve.js";
15
+ import { gatewayKeyIndex, providersWithoutCredentials } from "./resolve.js";
16
16
  import { PrefixStore } from "./prefix_store.js";
17
+ import { SessionPinStore } from "./session_pin_store.js";
17
18
  import { handleSubscriptionRequest, SubscriptionStore } from "./subscriptions/index.js";
18
19
 
19
20
  const SUBSCRIPTION_ROUTES = "/v1/organizations/current/credentials";
@@ -27,7 +28,8 @@ const SUBSCRIPTION_ROUTES = "/v1/organizations/current/credentials";
27
28
  * state (default `dari-gateway.db`). It holds subscription tokens.
28
29
  * - `DARI_AUTO_ROUTING_API_KEY`: choose among a router's models with Dari
29
30
  * Auto Routing; without it, `dari/routing` serves the first listed model.
30
- * - `PORT` (default 8080), and each provider's standard API key variable.
31
+ * - `PORT` (default 8080), `DARI_GATEWAY_HOST` (default 127.0.0.1; the container
32
+ * image sets 0.0.0.0), and each provider's standard API key variable.
31
33
  */
32
34
  export async function startSelfHostedGateway(env: Record<string, string> = process.env as Record<string, string>) {
33
35
  const gatewayKeys = (env.DARI_GATEWAY_KEYS ?? "")
@@ -45,21 +47,35 @@ export async function startSelfHostedGateway(env: Record<string, string> = proce
45
47
  db.exec("PRAGMA journal_mode = WAL");
46
48
  const subscriptions = new SubscriptionStore(db);
47
49
  const prefixes = new PrefixStore(db);
50
+ const sessionPins = new SessionPinStore(db);
48
51
  // Pi's provider auth reads each provider's standard key variable, such as
49
52
  // OPENAI_API_KEY, from this environment only.
50
53
  const piModels = builtinModels({
51
54
  authContext: { env: async (name) => env[name], fileExists: async () => false },
52
55
  });
53
56
 
57
+ const providerApiKey = async (provider: string) => (await piModels.getAuth(provider))?.auth.apiKey;
54
58
  const platform = createSelfHostedPlatform({
55
59
  config,
56
60
  gatewayKeys,
57
61
  subscriptions,
58
62
  prefixes,
59
- providerApiKey: async (provider) => (await piModels.getAuth(provider))?.auth.apiKey,
63
+ sessionPins,
64
+ providerApiKey,
60
65
  env,
61
66
  fetch,
62
67
  });
68
+ // A model without a credential is dropped at request time, so a missing key
69
+ // would otherwise surface only as a 424 on the first request.
70
+ for (const [routerId, router] of Object.entries(config.routers)) {
71
+ const missing = await providersWithoutCredentials({ config, subscriptions, providerApiKey, env }, router);
72
+ if (missing.length > 0) {
73
+ console.warn(
74
+ "dari-gateway router has providers without credentials; their models are not served",
75
+ JSON.stringify({ router: routerId, providers: missing }),
76
+ );
77
+ }
78
+ }
63
79
  const autoRoutingKey = env.DARI_AUTO_ROUTING_API_KEY?.trim();
64
80
  const selectorBackend: SelectorBackend = autoRoutingKey
65
81
  ? createAutoRoutingSelectorBackend({
@@ -86,10 +102,13 @@ export async function startSelfHostedGateway(env: Record<string, string> = proce
86
102
  };
87
103
 
88
104
  const port = Number(env.PORT ?? "8080");
89
- const server = startServer(port, handler);
105
+ // Loopback unless DARI_GATEWAY_HOST says otherwise (see the doc comment).
106
+ const hostname = (env.DARI_GATEWAY_HOST ?? "").trim() || "127.0.0.1";
107
+ const server = startServer(port, handler, { hostname });
90
108
  console.info(
91
109
  "dari-gateway listening",
92
110
  JSON.stringify({
111
+ hostname,
93
112
  port,
94
113
  routers: Object.keys(config.routers),
95
114
  auto_routing: autoRoutingKey ? "dari" : "priority",
@@ -1,16 +1,18 @@
1
1
  import { errorCode } from "../http/errors.js";
2
2
  import type { GatewayPlatform } from "../platform/platform.js";
3
3
  import type { PrefixStore } from "./prefix_store.js";
4
+ import type { SessionPinStore } from "./session_pin_store.js";
4
5
  import { resolveSelfHostedRouter, type SelfHostedResolverDeps } from "./resolve.js";
5
6
  import type { SubscriptionStore } from "./subscriptions/index.js";
6
7
 
7
8
  /**
8
- * The self-hosted platform: routers from the config file, subscriptions and
9
- * prefix-cache state in the gateway's SQLite database, and one content-free
9
+ * The self-hosted platform: routers from the config file, subscriptions,
10
+ * prefix-cache state, and session pins in the gateway's SQLite database, and one content-free
10
11
  * log line per request.
11
12
  */
12
13
  export function createSelfHostedPlatform(deps: Omit<SelfHostedResolverDeps, "prefixHits"> & {
13
14
  prefixes: PrefixStore;
15
+ sessionPins: SessionPinStore;
14
16
  subscriptions: SubscriptionStore;
15
17
  }): GatewayPlatform {
16
18
  const resolver: SelfHostedResolverDeps = {
@@ -25,6 +27,10 @@ export function createSelfHostedPlatform(deps: Omit<SelfHostedResolverDeps, "pre
25
27
  writePendingLease: async (write) => {
26
28
  deps.prefixes.writePendingLease(write, deps.config.routers[write.router_id]?.zdr ?? false);
27
29
  },
30
+ readSessionPin: async (key) => deps.sessionPins.read(key),
31
+ writeSessionPin: async (write) => {
32
+ deps.sessionPins.write(write);
33
+ },
28
34
  markSubscriptionUnavailable: async (hold) =>
29
35
  hold.credentialId !== undefined &&
30
36
  deps.subscriptions.markUnavailable(hold.credentialId, hold.limit.resetsAt, hold.limit.reason),