@mupt-ai/dari-gateway 0.3.0 → 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 (110) 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/provider_reported_cost.js +17 -2
  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 +5 -0
  31. package/dist/runtime/harness_identity/conversation.js +6 -0
  32. package/dist/runtime/harness_identity/extract.d.ts +1 -0
  33. package/dist/runtime/harness_identity/extract.js +13 -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 +1 -2
  47. package/dist/self_host/main.d.ts +2 -1
  48. package/dist/self_host/main.js +20 -4
  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/http/auth.ts +2 -2
  70. package/src/http/http2_server.ts +7 -3
  71. package/src/http/serve.ts +6 -5
  72. package/src/ingress/chat_completions.ts +2 -1
  73. package/src/ingress/codex.ts +4 -1
  74. package/src/ingress/codex_models.ts +18 -7
  75. package/src/platform/platform.ts +16 -2
  76. package/src/protocol/input/openai_responses.ts +2 -9
  77. package/src/protocol/output/anthropic_messages_output.ts +3 -2
  78. package/src/protocol/output/openai_responses_stream.ts +17 -1
  79. package/src/providers/execution.ts +5 -15
  80. package/src/providers/pi_output.ts +1 -1
  81. package/src/providers/provider_attempt_runner.ts +10 -21
  82. package/src/providers/provider_call.ts +3 -1
  83. package/src/providers/provider_reported_cost.ts +16 -2
  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 +11 -0
  89. package/src/runtime/harness_identity/extract.ts +14 -5
  90. package/src/runtime/harness_identity/index.ts +2 -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 +3 -6
  97. package/src/self_host/main.ts +23 -4
  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
@@ -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),
@@ -58,7 +58,7 @@ export async function resolveSelfHostedRouter(
58
58
  ): Promise<ResolvedRouter> {
59
59
  const keyIndex = gatewayKeyIndex(deps.gatewayKeys, request.apiKey);
60
60
  if (keyIndex === null) {
61
- throw new HttpError(401, "Missing or invalid router/API key.", "routing_key_resolution_failed");
61
+ throw new HttpError(401, "Missing or invalid gateway key.", "authentication_error");
62
62
  }
63
63
  const router = deps.config.routers[request.routerId];
64
64
  if (router === undefined) {
@@ -91,7 +91,10 @@ export async function resolveSelfHostedRouter(
91
91
  }
92
92
 
93
93
  const models = router.models.filter((model) => credentials.get(providerForModel(model.id)) != null);
94
- if (models.length === 0) throw unavailableRouterError(request.routerId, exhausted);
94
+ if (models.length === 0) {
95
+ const missing = [...credentials].filter(([, credential]) => credential == null).map(([provider]) => provider);
96
+ throw unavailableRouterError(request.routerId, exhausted, missing);
97
+ }
95
98
 
96
99
  const resolved: ResolvedRouter = {
97
100
  router_id: request.routerId,
@@ -104,9 +107,8 @@ export async function resolveSelfHostedRouter(
104
107
  subscription_credential_ids: {},
105
108
  zdr: router.zdr,
106
109
  speculative_routing: false,
110
+ session_pinning: router.sessionPinning,
107
111
  primary_retries: router.retries,
108
- model_fallback_enabled: router.fallback,
109
- fallback_requires_different_provider: router.fallbackRequiresDifferentProvider,
110
112
  lease_context_enabled: false,
111
113
  routing_strategy: "dari",
112
114
  custom_config: null,
@@ -152,7 +154,10 @@ export async function resolveSelfHostedRouter(
152
154
 
153
155
  // Configured endpoints read their own key variable, or send a placeholder
154
156
  // when they take none; known providers use their standard variable.
155
- async function providerApiKey(deps: SelfHostedResolverDeps, provider: string): Promise<string | undefined> {
157
+ async function providerApiKey(
158
+ deps: Pick<SelfHostedResolverDeps, "config" | "providerApiKey" | "env">,
159
+ provider: string,
160
+ ): Promise<string | undefined> {
156
161
  const endpoint = deps.config.providers[provider];
157
162
  if (endpoint === undefined) return (await deps.providerApiKey(provider))?.trim() || undefined;
158
163
  if (endpoint.apiKeyEnv === undefined) return "no-key";
@@ -204,23 +209,48 @@ const SUBSCRIPTION_PLAN_NAMES: Record<SubscriptionProvider, string> = {
204
209
  meta_muse: "Muse Code",
205
210
  };
206
211
 
212
+ /**
213
+ * Providers a router lists that no credential can serve: no API key and no
214
+ * connected subscription the router allows. Startup uses this, so it counts a
215
+ * held subscription as connected (the request-time 424 reports holds) and
216
+ * checks both harnesses, since startup does not know the client.
217
+ */
218
+ export async function providersWithoutCredentials(
219
+ deps: Pick<SelfHostedResolverDeps, "subscriptions" | "providerApiKey" | "config" | "env">,
220
+ router: RouterConfig,
221
+ ): Promise<string[]> {
222
+ const missing: string[] = [];
223
+ for (const provider of new Set(router.models.map((model) => providerForModel(model.id)))) {
224
+ const plans = router.subscriptions
225
+ ? [subscriptionProviderFor(provider, "claude_code"), subscriptionProviderFor(provider, "muse_code")]
226
+ : [];
227
+ const connected = plans.some((plan) => plan !== null && deps.subscriptions.list(plan).length > 0);
228
+ if (connected) continue;
229
+ const apiKey = await providerApiKey(deps, provider);
230
+ if (apiKey === undefined) missing.push(provider);
231
+ }
232
+ return missing;
233
+ }
234
+
207
235
  function unavailableRouterError(
208
236
  routerId: string,
209
237
  exhausted: ReadonlySet<SubscriptionProvider>,
238
+ missing: readonly string[],
210
239
  ): HttpError {
240
+ const missingNote = missing.length > 0 ? ` Missing providers: ${missing.join(", ")}.` : "";
211
241
  if (exhausted.size > 0) {
212
242
  const plans = [...exhausted]
213
243
  .map((provider) => SUBSCRIPTION_PLAN_NAMES[provider])
214
244
  .join(" and ");
215
245
  return new HttpError(
216
246
  424,
217
- `Every connected ${plans} subscription is at its usage limit, and no API key can serve router ${routerId} instead.`,
247
+ `Every connected ${plans} subscription is at its usage limit, and no API key can serve router ${routerId} instead.${missingNote}`,
218
248
  "personal_subscription_unavailable",
219
249
  );
220
250
  }
221
251
  return new HttpError(
222
252
  424,
223
- `No credentials can serve router ${routerId}: set a provider API key or connect a subscription.`,
253
+ `No credentials can serve router ${routerId}.${missingNote} Set each provider's API key or connect a subscription that can serve it.`,
224
254
  "provider_credentials_missing",
225
255
  );
226
256
  }
@@ -0,0 +1,58 @@
1
+ /**
2
+ * SQLite storage for session pins: the model and thinking level each session
3
+ * (and each subagent) of a session-pinning router was routed to on its first
4
+ * turn. Serving a pin refreshes it at most daily; pins unused for longer than
5
+ * the retention window are no longer served and are deleted.
6
+ */
7
+ import type { Database } from "bun:sqlite";
8
+
9
+ import type { SessionPin, SessionPinKey } from "../platform/platform.js";
10
+
11
+ const RETENTION_MS = 30 * 24 * 60 * 60_000;
12
+ const REFRESH_MS = 24 * 60 * 60_000;
13
+
14
+ type PinRow = { model: string; reasoning_effort: SessionPin["reasoningEffort"]; updated_at: number };
15
+
16
+ export class SessionPinStore {
17
+ constructor(private readonly db: Database) {
18
+ db.run(`CREATE TABLE IF NOT EXISTS routing_session_pins (
19
+ router_id TEXT NOT NULL,
20
+ pin_key TEXT NOT NULL,
21
+ model TEXT NOT NULL,
22
+ reasoning_effort TEXT NOT NULL,
23
+ updated_at INTEGER NOT NULL,
24
+ PRIMARY KEY (router_id, pin_key)
25
+ )`);
26
+ db.run("CREATE INDEX IF NOT EXISTS ix_routing_session_pins_updated_at ON routing_session_pins (updated_at)");
27
+ }
28
+
29
+ read(key: SessionPinKey, now: Date = new Date()): SessionPin | null {
30
+ const row = this.db
31
+ .query<PinRow, [string, string, number]>(
32
+ `SELECT model, reasoning_effort, updated_at FROM routing_session_pins
33
+ WHERE router_id = ? AND pin_key = ? AND updated_at >= ?`,
34
+ )
35
+ .get(key.routerId, key.pinKey, now.getTime() - RETENTION_MS);
36
+ if (row === null) return null;
37
+ if (row.updated_at < now.getTime() - REFRESH_MS) {
38
+ this.db
39
+ .query("UPDATE routing_session_pins SET updated_at = ? WHERE router_id = ? AND pin_key = ?")
40
+ .run(now.getTime(), key.routerId, key.pinKey);
41
+ }
42
+ return { model: row.model, reasoningEffort: row.reasoning_effort };
43
+ }
44
+
45
+ write(write: SessionPinKey & SessionPin, now: Date = new Date()): void {
46
+ this.db.transaction(() => {
47
+ this.db
48
+ .query(`INSERT INTO routing_session_pins (router_id, pin_key, model, reasoning_effort, updated_at)
49
+ VALUES (?, ?, ?, ?, ?)
50
+ ON CONFLICT (router_id, pin_key) DO UPDATE SET
51
+ model = excluded.model,
52
+ reasoning_effort = excluded.reasoning_effort,
53
+ updated_at = excluded.updated_at`)
54
+ .run(write.routerId, write.pinKey, write.model, write.reasoningEffort, now.getTime());
55
+ this.db.query("DELETE FROM routing_session_pins WHERE updated_at < ?").run(now.getTime() - RETENTION_MS);
56
+ })();
57
+ }
58
+ }
@@ -7,8 +7,7 @@ const PRIORITY_CONTEXT_WINDOW_CHARS = 1_000_000;
7
7
 
8
8
  /**
9
9
  * Serve the router's first eligible model/thinking-level pair, in the order
10
- * the router lists them. Provider failures still fall back to the next pair
11
- * when the router enables model fallback.
10
+ * the router lists them.
12
11
  */
13
12
  export function createPrioritySelectorBackend(): SelectorBackend {
14
13
  return {
@@ -15,7 +15,6 @@ import type { ResolvedRouter } from "../types.js";
15
15
 
16
16
  export type RoutedDecision = {
17
17
  decision: RoutingDecision;
18
- fallbackDecision?: RoutingDecision;
19
18
  run?: SelectorRun;
20
19
  details?: Record<string, unknown>;
21
20
  };
@@ -81,9 +80,6 @@ export async function routeDecision(
81
80
  ? { customRules: custom.rules, defaultTarget: custom.defaultTarget }
82
81
  : {}),
83
82
  selectorBackend: args.selectorBackend,
84
- modelFallbackEnabled: credentials.model_fallback_enabled !== false,
85
- fallbackRequiresDifferentProvider:
86
- credentials.fallback_requires_different_provider !== false,
87
83
  executionProviderForModel: (model) =>
88
84
  executionProviderForModel(credentials, model),
89
85
  imputeEvalScores: credentials.eval_score_imputation === true,
@@ -99,7 +95,6 @@ export async function routeDecision(
99
95
  };
100
96
  return {
101
97
  decision: run.decision,
102
- fallbackDecision: run.fallbackDecision,
103
98
  run,
104
99
  ...(Object.keys(details).length > 0 ? { details } : {}),
105
100
  };
@@ -34,7 +34,6 @@ import type {
34
34
  ProviderAuth,
35
35
  ResolvedRouter,
36
36
  } from "../types.js";
37
- import { deriveFallbackDecision } from "./fallback_decision.js";
38
37
  import type { SelectorImage } from "./selector_projection.js";
39
38
 
40
39
  export function resolveRouter(args: ResolveRouterRequest): Promise<ResolvedRouter> {
@@ -54,7 +53,6 @@ export type SelectorTrace = {
54
53
 
55
54
  export type SelectorRun = SelectorTrace & {
56
55
  decision: RoutingDecision;
57
- fallbackDecision?: RoutingDecision;
58
56
  };
59
57
 
60
58
  export type SelectModelArgs = {
@@ -70,8 +68,6 @@ export type SelectModelArgs = {
70
68
  customRules?: CustomRouterRule[];
71
69
  defaultTarget?: CustomDefaultTarget | null;
72
70
  sessionId?: string;
73
- modelFallbackEnabled?: boolean;
74
- fallbackRequiresDifferentProvider?: boolean;
75
71
  executionProviderForModel?: (model: string) => string;
76
72
  imputeEvalScores?: boolean;
77
73
  imputationReferenceEvals?: RouterEval[];
@@ -171,8 +167,6 @@ export async function selectModel(
171
167
  contextWindowChars,
172
168
  messages: selectorBackend.prepareMessages?.(safeMessages) ?? safeMessages,
173
169
  ...(leaseContextEnabled ? { leaseHistory: args.leaseHistory ?? [] } : {}),
174
- modelFallbackEnabled: args.modelFallbackEnabled,
175
- fallbackRequiresDifferentProvider: args.fallbackRequiresDifferentProvider,
176
170
  imputeEvalScores: args.imputeEvalScores,
177
171
  imputationReferenceEvals: args.imputationReferenceEvals,
178
172
  });
@@ -285,17 +279,7 @@ function validateSelectorRun(run: SelectorRun, args: SelectModelArgs): SelectorR
285
279
  run,
286
280
  );
287
281
  }
288
- const { fallbackDecision: _selectorFallback, ...primaryRun } = run;
289
- const fallbackDecision = deriveFallbackDecision({
290
- candidates: args.candidates,
291
- selected,
292
- enabled: args.modelFallbackEnabled,
293
- requiresDifferentProvider: args.fallbackRequiresDifferentProvider,
294
- providerForModel: args.executionProviderForModel,
295
- });
296
- return fallbackDecision
297
- ? { ...primaryRun, fallbackDecision }
298
- : primaryRun;
282
+ return run;
299
283
  }
300
284
 
301
285
  function requiredSelectorConfig(value: string, label: string): string {
@@ -14,7 +14,9 @@ export type RoutingBillingProviderAttemptFacts = {
14
14
  provider: string;
15
15
  model: string;
16
16
  provider_key_source: ProviderKeySource;
17
- role: "primary" | "fallback";
17
+ // The control-plane billing contract numbers attempts within a role; every
18
+ // attempt retries the routed choice, so the role is always primary.
19
+ role: "primary";
18
20
  attempt: number;
19
21
  outcome: "completed" | "provider_failed" | "customer_canceled";
20
22
  provider_request_id: string | null;
@@ -94,7 +96,6 @@ export function routingBillingProviderAttemptFacts(args: {
94
96
  provider: string;
95
97
  model: string;
96
98
  providerKeySource: ProviderKeySource;
97
- role: "primary" | "fallback";
98
99
  attempt: number;
99
100
  outcome: "completed" | "provider_failed" | "customer_canceled";
100
101
  providerMessage: Record<string, unknown> | null;
@@ -129,7 +130,7 @@ export function routingBillingProviderAttemptFacts(args: {
129
130
  provider: args.provider,
130
131
  model: args.model,
131
132
  provider_key_source: args.providerKeySource,
132
- role: args.role,
133
+ role: "primary",
133
134
  attempt: args.attempt,
134
135
  outcome: args.outcome,
135
136
  provider_request_id: providerRequestId(args.providerMessage),
@@ -80,15 +80,10 @@ export function buildCompletedPrefixHit(args: {
80
80
  export function leaseCountdownForPrefix(args: {
81
81
  served: RoutingDecision | null;
82
82
  next: RoutingDecision | null;
83
- // True when a completed fallback attempt served this turn. A lease-aware
84
- // fallback carries the remaining countdown on the serving decision; an
85
- // ordinary fallback has no countdown and ends any primary commitment.
86
- fallbackServed?: boolean;
87
83
  }): number | null {
88
- const granted = args.fallbackServed === true ? args.served : args.next ?? args.served;
84
+ const granted = args.next ?? args.served;
89
85
  const remaining = granted?.leaseTurnsRemaining;
90
86
  if (remaining === undefined || remaining === null) return null;
91
- if (args.fallbackServed === true) return remaining;
92
87
  const servedGrantedPair =
93
88
  args.next === null ||
94
89
  (args.served !== null &&
@@ -62,7 +62,6 @@ import {
62
62
  buildCompletedPrefixHit,
63
63
  leaseCountdownForPrefix,
64
64
  prefixEntryForIngest,
65
- reportPendingLease,
66
65
  reportPrefixState,
67
66
  } from "./prefix_state.js";
68
67
  import { subscriptionUsageLimitFromFailure } from "../providers/subscription_limits.js";
@@ -149,7 +148,6 @@ export class RequestReporter {
149
148
  private selectorOutputAccepted = false;
150
149
  private decision: RoutingDecision | null = null;
151
150
  private nextDecision: RoutingDecision | null = null;
152
- private fallbackDecision: RoutingDecision | null = null;
153
151
  private providerAttempts: ProviderAttemptReport[] = [];
154
152
  private billingProviderAttempts: RoutingBillingProviderAttemptFacts[] = [];
155
153
  private decisionDetails: Record<string, unknown> = {};
@@ -202,7 +200,6 @@ export class RequestReporter {
202
200
  this.selectorOutputAccepted = true;
203
201
  this.decision = run.decision;
204
202
  this.nextDecision = run.decision;
205
- this.fallbackDecision = run.fallbackDecision ?? null;
206
203
  }
207
204
 
208
205
  attachSpeculativeSelectorRun(run: SelectorRun, servingDecision: RoutingDecision): void {
@@ -211,7 +208,6 @@ export class RequestReporter {
211
208
  this.selectorOutputAccepted = true;
212
209
  this.decision = servingDecision;
213
210
  this.nextDecision = run.decision;
214
- this.fallbackDecision = run.fallbackDecision ?? null;
215
211
  }
216
212
 
217
213
  // Speculative execution can begin before a selector run; its serving
@@ -221,10 +217,6 @@ export class RequestReporter {
221
217
  this.nextDecision = decision;
222
218
  }
223
219
 
224
- attachFallbackDecision(decision: RoutingDecision | undefined): void {
225
- this.fallbackDecision = decision ?? null;
226
- }
227
-
228
220
  attachServingDecision(decision: RoutingDecision): void {
229
221
  this.decision = decision;
230
222
  }
@@ -239,10 +231,10 @@ export class RequestReporter {
239
231
  return modelCost(attempt ? [...this.billingProviderAttempts, this.billingFacts(attempt)] : this.billingProviderAttempts);
240
232
  }
241
233
 
242
- private billingFacts({ execution, message, role, attempt, billingOutcome }: ProviderAttemptRecord): RoutingBillingProviderAttemptFacts {
234
+ private billingFacts({ execution, message, attempt, billingOutcome }: ProviderAttemptRecord): RoutingBillingProviderAttemptFacts {
243
235
  return routingBillingProviderAttemptFacts({
244
236
  provider: execution.provider, model: execution.model,
245
- providerKeySource: execution.providerKeySource, role, attempt, outcome: billingOutcome,
237
+ providerKeySource: execution.providerKeySource, attempt, outcome: billingOutcome,
246
238
  providerMessage: message, usage: isRecord(message.usage) ? message.usage : null,
247
239
  providerPricingKnown: this.pricingKnown(execution.model),
248
240
  });
@@ -388,18 +380,8 @@ export class RequestReporter {
388
380
  const decision = this.decision;
389
381
  const selectedModel = outcome.providerExecution?.model ?? decision?.selectedModel ?? null;
390
382
  const reasoningBucket = decision?.reasoningEffort ?? null;
391
- // A completed fallback attempt means the intended pair failed this
392
- // turn: the next-turn recommendation and any lease countdown follow the
393
- // pair that actually served instead of re-pinning the failed one.
394
- const fallbackServed = this.providerAttempts.some(
395
- (attempt) => attempt.role === "fallback" && attempt.outcome === "completed",
396
- );
397
- const nextModel = fallbackServed
398
- ? selectedModel
399
- : this.nextDecision?.selectedModel ?? selectedModel;
400
- const nextReasoningEffort = fallbackServed
401
- ? reasoningBucket
402
- : this.nextDecision?.reasoningEffort ?? reasoningBucket;
383
+ const nextModel = this.nextDecision?.selectedModel ?? selectedModel;
384
+ const nextReasoningEffort = this.nextDecision?.reasoningEffort ?? reasoningBucket;
403
385
  const selectedProvider = outcome.providerExecution?.provider ?? (selectedModel
404
386
  ? executionProviderForModel(a.credentials, selectedModel)
405
387
  : null);
@@ -470,7 +452,6 @@ export class RequestReporter {
470
452
  leaseTurnsRemaining: leaseCountdownForPrefix({
471
453
  served: decision ?? null,
472
454
  next: this.nextDecision,
473
- fallbackServed,
474
455
  }),
475
456
  reason: this.nextDecision?.reason ?? decision?.reason ?? null,
476
457
  chain: selectedChain,
@@ -506,21 +487,6 @@ export class RequestReporter {
506
487
  // resolves this turn's conversation id and next-model prediction.
507
488
  // The full report below re-applies the same state idempotently, so a
508
489
  // lost or timed-out fast write only degrades to the old ordering.
509
- // An ordinary fallback breaks any lease commitment, prefetched ones
510
- // included. A lease-aware fallback transfers the active countdown to
511
- // the serving pair, so only clear pending state when no countdown was
512
- // transferred. The depth fence orders the clear safely against
513
- // in-flight prefetch writes.
514
- if (!backgroundRequest && fallbackServed && decision?.leaseTurnsRemaining === undefined) {
515
- trackBackgroundTelemetry(
516
- reportPendingLease({
517
- routerId: a.routerId,
518
- conversationId: a.conversationId,
519
- decidedAtDepth: a.leaseDepth,
520
- lease: null,
521
- }),
522
- );
523
- }
524
490
  if (prefixEntry !== null || touchedHash !== null) {
525
491
  this.prefixStatePromise = reportPrefixState({
526
492
  organization_id: a.credentials.organization_id,
@@ -592,13 +558,6 @@ export class RequestReporter {
592
558
  strategy: a.strategy,
593
559
  selected_reasoning_effort: reasoningBucket,
594
560
  next_reasoning_effort: nextReasoningEffort,
595
- ...(this.fallbackDecision
596
- ? {
597
- fallback_model: this.fallbackDecision.selectedModel,
598
- fallback_reasoning_effort:
599
- this.fallbackDecision.reasoningEffort,
600
- }
601
- : {}),
602
561
  ...(this.providerAttempts.length > 0
603
562
  ? { provider_attempts: this.providerAttempts }
604
563
  : {}),
@@ -662,9 +621,6 @@ export class RequestReporter {
662
621
  selected_reasoning_effort: reasoningBucket,
663
622
  next_model: nextModel,
664
623
  next_reasoning_effort: nextReasoningEffort,
665
- fallback_model: this.fallbackDecision?.selectedModel ?? null,
666
- fallback_reasoning_effort:
667
- this.fallbackDecision?.reasoningEffort ?? null,
668
624
  provider_key_source: keySource,
669
625
  message_count: observability.messageCount,
670
626
  tool_count: observability.metadata.declared_tool_counts.total,
package/src/types.ts CHANGED
@@ -6,6 +6,7 @@ import type {
6
6
  RouterEvalScore,
7
7
  RouterModelPrice,
8
8
  RouterPrefixHit,
9
+ RoutingDecision,
9
10
  RoutingStrategy,
10
11
  } from "@mupt-ai/dari-router/policy-engine";
11
12
  // The router resolution contract between the gateway runtime and its
@@ -83,6 +84,10 @@ export type PublicEvalCatalogCard = Omit<RouterEval, "scores"> & {
83
84
  scores: PublicEvalCatalogScore[];
84
85
  };
85
86
 
87
+ // A decision as served: session-pinned decisions say so in response headers
88
+ // and Anthropic routing metadata.
89
+ export type ServedDecision = RoutingDecision & { sessionPinned?: boolean };
90
+
86
91
  export type ClientHarness = "claude_code" | "muse_code";
87
92
 
88
93
  export type ResolvedRouterModelConfig = {
@@ -122,17 +127,18 @@ export type ResolvedRouter = {
122
127
  zdr?: boolean;
123
128
  allow_long_context?: boolean;
124
129
  speculative_routing?: boolean;
130
+ // Route each session's (and each subagent's) first turn, then serve that
131
+ // pair for the rest of the session instead of selecting or leasing.
132
+ session_pinning?: boolean;
125
133
  primary_retries?: number;
126
134
  // Serve only the requested `dari/model/<id>` at the requested thinking
127
- // level: no selector, lease, fallback, or personal subscription the
128
- // platform did not supply through resolveProviderCredential, and an error
129
- // for anything else, whatever the client sends.
135
+ // level: no selector, lease, or personal subscription the platform did not
136
+ // supply through resolveProviderCredential, and an error for anything
137
+ // else, whatever the client sends.
130
138
  strict?: boolean;
131
139
  // On strict routers only, allow requested catalog models of these providers.
132
- // Requires dari/model/<provider>/<id>; never enables fallback.
140
+ // Requires dari/model/<provider>/<id>.
133
141
  strict_providers?: string[];
134
- model_fallback_enabled?: boolean;
135
- fallback_requires_different_provider?: boolean;
136
142
  evals?: RouterEval[];
137
143
  // When true, fill missing eval scores using thinking-level ratios or an
138
144
  // explicitly mapped predecessor model.