@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
@@ -16,7 +16,7 @@ import { routingBillingProviderAttemptFacts, } from "./billing_finalization.js";
16
16
  import { estimateBenchmarkReplay } from "./benchmark_replay.js";
17
17
  import { extractInteractionObservability } from "./requests/interaction_observability.js";
18
18
  import { costString, finiteNumber, isRecord, tokenCount, } from "./ingest_values.js";
19
- import { buildCompletedPrefixHit, leaseCountdownForPrefix, prefixEntryForIngest, reportPendingLease, reportPrefixState, } from "./prefix_state.js";
19
+ import { buildCompletedPrefixHit, leaseCountdownForPrefix, prefixEntryForIngest, reportPrefixState, } from "./prefix_state.js";
20
20
  import { subscriptionUsageLimitFromFailure } from "../providers/subscription_limits.js";
21
21
  import { scrubSecrets } from "./scrub.js";
22
22
  import { encodeRetainedInteractionRequest } from "./requests/retained_interaction_request.js";
@@ -50,7 +50,6 @@ export class RequestReporter {
50
50
  selectorOutputAccepted = false;
51
51
  decision = null;
52
52
  nextDecision = null;
53
- fallbackDecision = null;
54
53
  providerAttempts = [];
55
54
  billingProviderAttempts = [];
56
55
  decisionDetails = {};
@@ -66,7 +65,6 @@ export class RequestReporter {
66
65
  this.selectorOutputAccepted = true;
67
66
  this.decision = run.decision;
68
67
  this.nextDecision = run.decision;
69
- this.fallbackDecision = run.fallbackDecision ?? null;
70
68
  }
71
69
  attachSpeculativeSelectorRun(run, servingDecision) {
72
70
  this.selectorRun = run;
@@ -74,7 +72,6 @@ export class RequestReporter {
74
72
  this.selectorOutputAccepted = true;
75
73
  this.decision = servingDecision;
76
74
  this.nextDecision = run.decision;
77
- this.fallbackDecision = run.fallbackDecision ?? null;
78
75
  }
79
76
  // Speculative execution can begin before a selector run; its serving
80
77
  // decision drives selected-model attribution, prefix entries, and errors.
@@ -82,9 +79,6 @@ export class RequestReporter {
82
79
  this.decision = decision;
83
80
  this.nextDecision = decision;
84
81
  }
85
- attachFallbackDecision(decision) {
86
- this.fallbackDecision = decision ?? null;
87
- }
88
82
  attachServingDecision(decision) {
89
83
  this.decision = decision;
90
84
  }
@@ -96,10 +90,10 @@ export class RequestReporter {
96
90
  modelCost(attempt) {
97
91
  return modelCost(attempt ? [...this.billingProviderAttempts, this.billingFacts(attempt)] : this.billingProviderAttempts);
98
92
  }
99
- billingFacts({ execution, message, role, attempt, billingOutcome }) {
93
+ billingFacts({ execution, message, attempt, billingOutcome }) {
100
94
  return routingBillingProviderAttemptFacts({
101
95
  provider: execution.provider, model: execution.model,
102
- providerKeySource: execution.providerKeySource, role, attempt, outcome: billingOutcome,
96
+ providerKeySource: execution.providerKeySource, attempt, outcome: billingOutcome,
103
97
  providerMessage: message, usage: isRecord(message.usage) ? message.usage : null,
104
98
  providerPricingKnown: this.pricingKnown(execution.model),
105
99
  });
@@ -224,16 +218,8 @@ export class RequestReporter {
224
218
  const decision = this.decision;
225
219
  const selectedModel = outcome.providerExecution?.model ?? decision?.selectedModel ?? null;
226
220
  const reasoningBucket = decision?.reasoningEffort ?? null;
227
- // A completed fallback attempt means the intended pair failed this
228
- // turn: the next-turn recommendation and any lease countdown follow the
229
- // pair that actually served instead of re-pinning the failed one.
230
- const fallbackServed = this.providerAttempts.some((attempt) => attempt.role === "fallback" && attempt.outcome === "completed");
231
- const nextModel = fallbackServed
232
- ? selectedModel
233
- : this.nextDecision?.selectedModel ?? selectedModel;
234
- const nextReasoningEffort = fallbackServed
235
- ? reasoningBucket
236
- : this.nextDecision?.reasoningEffort ?? reasoningBucket;
221
+ const nextModel = this.nextDecision?.selectedModel ?? selectedModel;
222
+ const nextReasoningEffort = this.nextDecision?.reasoningEffort ?? reasoningBucket;
237
223
  const selectedProvider = outcome.providerExecution?.provider ?? (selectedModel
238
224
  ? executionProviderForModel(a.credentials, selectedModel)
239
225
  : null);
@@ -292,7 +278,6 @@ export class RequestReporter {
292
278
  leaseTurnsRemaining: leaseCountdownForPrefix({
293
279
  served: decision ?? null,
294
280
  next: this.nextDecision,
295
- fallbackServed,
296
281
  }),
297
282
  reason: this.nextDecision?.reason ?? decision?.reason ?? null,
298
283
  chain: selectedChain,
@@ -324,19 +309,6 @@ export class RequestReporter {
324
309
  // resolves this turn's conversation id and next-model prediction.
325
310
  // The full report below re-applies the same state idempotently, so a
326
311
  // lost or timed-out fast write only degrades to the old ordering.
327
- // An ordinary fallback breaks any lease commitment, prefetched ones
328
- // included. A lease-aware fallback transfers the active countdown to
329
- // the serving pair, so only clear pending state when no countdown was
330
- // transferred. The depth fence orders the clear safely against
331
- // in-flight prefetch writes.
332
- if (!backgroundRequest && fallbackServed && decision?.leaseTurnsRemaining === undefined) {
333
- trackBackgroundTelemetry(reportPendingLease({
334
- routerId: a.routerId,
335
- conversationId: a.conversationId,
336
- decidedAtDepth: a.leaseDepth,
337
- lease: null,
338
- }));
339
- }
340
312
  if (prefixEntry !== null || touchedHash !== null) {
341
313
  this.prefixStatePromise = reportPrefixState({
342
314
  organization_id: a.credentials.organization_id,
@@ -404,12 +376,6 @@ export class RequestReporter {
404
376
  strategy: a.strategy,
405
377
  selected_reasoning_effort: reasoningBucket,
406
378
  next_reasoning_effort: nextReasoningEffort,
407
- ...(this.fallbackDecision
408
- ? {
409
- fallback_model: this.fallbackDecision.selectedModel,
410
- fallback_reasoning_effort: this.fallbackDecision.reasoningEffort,
411
- }
412
- : {}),
413
379
  ...(this.providerAttempts.length > 0
414
380
  ? { provider_attempts: this.providerAttempts }
415
381
  : {}),
@@ -466,8 +432,6 @@ export class RequestReporter {
466
432
  selected_reasoning_effort: reasoningBucket,
467
433
  next_model: nextModel,
468
434
  next_reasoning_effort: nextReasoningEffort,
469
- fallback_model: this.fallbackDecision?.selectedModel ?? null,
470
- fallback_reasoning_effort: this.fallbackDecision?.reasoningEffort ?? null,
471
435
  provider_key_source: keySource,
472
436
  message_count: observability.messageCount,
473
437
  tool_count: observability.metadata.declared_tool_counts.total,
package/dist/types.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { CustomRouterConfig, Provider, ReasoningEffort, RouterEval, RouterEvalScore, RouterModelPrice, RouterPrefixHit, RoutingStrategy } from "@mupt-ai/dari-router/policy-engine";
1
+ import type { CustomRouterConfig, Provider, ReasoningEffort, RouterEval, RouterEvalScore, RouterModelPrice, RouterPrefixHit, RoutingDecision, RoutingStrategy } from "@mupt-ai/dari-router/policy-engine";
2
2
  export type ProviderKeySource = "user" | "managed" | "subscription";
3
3
  export type BearerProviderAuth = {
4
4
  type: "bearer";
@@ -41,6 +41,9 @@ export type PublicEvalCatalogCard = Omit<RouterEval, "scores"> & {
41
41
  updated_at: string | null;
42
42
  scores: PublicEvalCatalogScore[];
43
43
  };
44
+ export type ServedDecision = RoutingDecision & {
45
+ sessionPinned?: boolean;
46
+ };
44
47
  export type ClientHarness = "claude_code" | "muse_code";
45
48
  export type ResolvedRouterModelConfig = {
46
49
  provider: string;
@@ -74,11 +77,10 @@ export type ResolvedRouter = {
74
77
  zdr?: boolean;
75
78
  allow_long_context?: boolean;
76
79
  speculative_routing?: boolean;
80
+ session_pinning?: boolean;
77
81
  primary_retries?: number;
78
82
  strict?: boolean;
79
83
  strict_providers?: string[];
80
- model_fallback_enabled?: boolean;
81
- fallback_requires_different_provider?: boolean;
82
84
  evals?: RouterEval[];
83
85
  eval_score_imputation?: boolean;
84
86
  image_capability_filtering?: boolean;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mupt-ai/dari-gateway",
3
- "version": "0.3.1",
3
+ "version": "0.3.3",
4
4
  "description": "Self-hostable LLM gateway for coding agents and APIs: one endpoint for Anthropic Messages, OpenAI Chat Completions, and OpenAI Responses, with subscription pooling, failover, and prompt-cache-aware routing.",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {
@@ -50,7 +50,7 @@
50
50
  "dependencies": {
51
51
  "@aws-sdk/client-sts": "3.1063.0",
52
52
  "@mupt-ai/dari-router": "0.2.5",
53
- "@mupt-ai/pi-ai": "1.4.0-mupt.1"
53
+ "@mupt-ai/pi-ai": "1.4.0-mupt.2"
54
54
  },
55
55
  "devDependencies": {
56
56
  "@types/bun": "^1.3.4",
@@ -105,7 +105,10 @@ export function interactionLooseChain(routerId: string, request: InteractionRequ
105
105
  // unrelated work reusing the same harness session gets a new group.
106
106
  export function interactionConversationPrefix(request: InteractionRequest): string {
107
107
  const firstStep = conversationFingerprintSteps(
108
- looseFingerprintSteps(request.sourceProtocol, request.items),
108
+ looseFingerprintSteps(
109
+ request.sourceProtocol,
110
+ request.items.filter((item) => !isOpenClawRuntimeContext(item)),
111
+ ),
109
112
  ).at(0);
110
113
  return sha256(stableStringify({
111
114
  version: CONVERSATION_PREFIX_VERSION,
@@ -115,6 +118,17 @@ export function interactionConversationPrefix(request: InteractionRequest): stri
115
118
  }));
116
119
  }
117
120
 
121
+ // OpenClaw appends a user message of runtime context (active exec sessions,
122
+ // subagents) to each request and drops it from later history. On a first
123
+ // turn it merges into the first transcript step, so leaving it in would give
124
+ // the first turn a different conversation than the turns after it.
125
+ const OPENCLAW_RUNTIME_CONTEXT = "<<<BEGIN_OPENCLAW_INTERNAL_CONTEXT>>>";
126
+
127
+ function isOpenClawRuntimeContext(item: InteractionItem): boolean {
128
+ return item.type === "message" && item.role === "user" && item.content.length > 0 &&
129
+ item.content.every((part) => part.type === "text" && part.text.trimStart().startsWith(OPENCLAW_RUNTIME_CONTEXT));
130
+ }
131
+
118
132
  export function interactionPrefixContext(
119
133
  routerId: string,
120
134
  request: InteractionRequest,
package/src/http/auth.ts CHANGED
@@ -6,7 +6,7 @@ export function bearerToken(headers: Headers): string {
6
6
  const value = headers.get("authorization") ?? "";
7
7
  const [scheme, token] = value.split(/\s+/, 2);
8
8
  if (scheme?.toLowerCase() !== "bearer" || !token) {
9
- throw new HttpError(401, "Missing or invalid routing API key.", "authentication_error");
9
+ throw new HttpError(401, "Missing or invalid API key.", "authentication_error");
10
10
  }
11
11
  return publicRoutingToken(token);
12
12
  }
@@ -16,7 +16,7 @@ export function anthropicToken(headers: Headers): string {
16
16
  if (authorization) return bearerToken(headers);
17
17
  const apiKey = headers.get("x-api-key");
18
18
  if (!apiKey) {
19
- throw new HttpError(401, "Missing or invalid routing API key.", "authentication_error");
19
+ throw new HttpError(401, "Missing or invalid API key.", "authentication_error");
20
20
  }
21
21
  return publicRoutingToken(apiKey);
22
22
  }
@@ -23,7 +23,11 @@ const HTTP2_FORBIDDEN_RESPONSE_HEADERS = new Set([
23
23
  "upgrade",
24
24
  ]);
25
25
 
26
- export function startHttp2Server(port: number, handler: Http2RequestHandler): Http2RoutingServer {
26
+ export function startHttp2Server(
27
+ port: number,
28
+ handler: Http2RequestHandler,
29
+ hostname = "0.0.0.0",
30
+ ): Http2RoutingServer {
27
31
  const sessions = new Set<ServerHttp2Session>();
28
32
  let closePromise: Promise<void> | undefined;
29
33
  const server = createServer((request, response) => {
@@ -33,10 +37,10 @@ export function startHttp2Server(port: number, handler: Http2RequestHandler): Ht
33
37
  sessions.add(session);
34
38
  session.once("close", () => sessions.delete(session));
35
39
  });
36
- server.listen(port, "0.0.0.0");
40
+ server.listen(port, hostname);
37
41
  const address = server.address();
38
42
  if (address === null || typeof address === "string") {
39
- throw new Error("HTTP/2 routing server did not bind to a TCP port.");
43
+ throw new Error("HTTP/2 gateway server did not bind to a TCP port.");
40
44
  }
41
45
  return {
42
46
  port: address.port,
package/src/http/serve.ts CHANGED
@@ -16,10 +16,10 @@ type StoppableServer = {
16
16
  export function startServer(
17
17
  listenPort: number,
18
18
  handler: (request: Request) => Promise<Response>,
19
- options: { http2?: boolean } = {},
19
+ options: { http2?: boolean; hostname?: string } = {},
20
20
  ) {
21
21
  if (options.http2) {
22
- return startHttp2Server(listenPort, handler);
22
+ return startHttp2Server(listenPort, handler, options.hostname);
23
23
  }
24
24
 
25
25
  // The selector runs before the first response byte, and routed models can
@@ -27,6 +27,7 @@ export function startServer(
27
27
  // clients and infrastructure still bound total request lifetime.
28
28
  return Bun.serve({
29
29
  port: listenPort,
30
+ hostname: options.hostname,
30
31
  fetch: handler,
31
32
  idleTimeout: 0,
32
33
  });
@@ -45,7 +46,7 @@ export function installShutdownHandlers(
45
46
  if (shuttingDown) return;
46
47
  shuttingDown = true;
47
48
  const workDeadlineAtMs = performance.now() + SHUTDOWN_WORK_TIMEOUT_MS;
48
- console.info("routing server shutting down", JSON.stringify({ signal }));
49
+ console.info("gateway server shutting down", JSON.stringify({ signal }));
49
50
  try {
50
51
  await stopServerForShutdown(server);
51
52
  } finally {
@@ -54,7 +55,7 @@ export function installShutdownHandlers(
54
55
  );
55
56
  if (remaining > 0) {
56
57
  console.warn(
57
- "routing server background drain timed out",
58
+ "gateway server background drain timed out",
58
59
  JSON.stringify({ remaining_task_count: remaining }),
59
60
  );
60
61
  captureOperationalError(
@@ -112,7 +113,7 @@ function stopWithin(
112
113
 
113
114
  function reportServerStopFailure(error: unknown): void {
114
115
  console.warn(
115
- "routing server shutdown failed",
116
+ "gateway server shutdown failed",
116
117
  error instanceof Error ? error.name : "UnknownError",
117
118
  );
118
119
  captureOperationalError(error, { code: "server_force_stop_failed" });
@@ -9,6 +9,7 @@ import type { SelectorBackend } from "../strategies/routing.js";
9
9
  import {
10
10
  chatCompletionsConversationIdentity,
11
11
  openCodeRequestContext,
12
+ piRequestContext,
12
13
  } from "../runtime/harness_identity/index.js";
13
14
  import { parseJsonRequest } from "./json.js";
14
15
  import { museCodeClientId } from "./muse_code.js";
@@ -22,7 +23,7 @@ export async function handleChatCompletions(
22
23
  const apiKey = bearerToken(request.headers);
23
24
  const payload = await parseJsonRequest<ChatCompletionRequest>(request);
24
25
  const interaction = chatCompletionsToInteraction({ ...payload, source_protocol: "openai_chat" });
25
- const agentContext = openCodeRequestContext(request.headers);
26
+ const agentContext = piRequestContext(request.headers) ?? openCodeRequestContext(request.headers);
26
27
  const museClientId = museCodeClientId(request.headers);
27
28
  return await handleRoutedCompletion({
28
29
  request,
@@ -1,3 +1,4 @@
1
+ import { explicitModelId } from "../protocol/input/router_model.js";
1
2
  import { bearerToken } from "../http/auth.js";
2
3
  import { errorResponse } from "../http/errors.js";
3
4
  import { captureRequestError } from "../observability/errors.js";
@@ -12,6 +13,7 @@ import {
12
13
  codexConversationIdentity,
13
14
  codexRequestPurpose,
14
15
  codexRequestContext,
16
+ museCodeConversationIdentity,
15
17
  } from "../runtime/harness_identity/index.js";
16
18
 
17
19
  export async function handleResponses(
@@ -32,15 +34,16 @@ export async function handleResponses(
32
34
  ...(agentContext === undefined ? {} : { agentContext }),
33
35
  ...(museClientId === undefined ? {} : { museCodeClientId: museClientId }),
34
36
  };
35
- const harnessConversationIdentity = codexConversationIdentity(
36
- request.headers,
37
- rawPayload,
38
- );
37
+ const codexIdentity = codexConversationIdentity(request.headers, rawPayload);
39
38
  // Codex applies its global model_reasoning_effort setting to custom model
40
39
  // providers. That preference describes a direct model, not a heterogeneous
41
40
  // router, so let Dari select the reasoning effort for verified Codex turns.
42
41
  // Parsing above still validates malformed Responses reasoning controls.
43
- if (harnessConversationIdentity !== undefined) payload.reasoning = undefined;
42
+ if (codexIdentity !== undefined && explicitModelId(payload.model) === null) {
43
+ payload.reasoning = undefined;
44
+ }
45
+ const harnessConversationIdentity = codexIdentity
46
+ ?? (museClientId === undefined ? undefined : museCodeConversationIdentity(request.headers));
44
47
  return await handleRoutedCompletion({
45
48
  request,
46
49
  routerId,
@@ -32,6 +32,8 @@ type CodexCatalogEntry = {
32
32
  contextWindow: number;
33
33
  hostedWebSearch: boolean;
34
34
  imageInput: boolean;
35
+ reasoningLevels?: string[];
36
+ defaultReasoningLevel?: string | null;
35
37
  };
36
38
 
37
39
  function codexCatalogEntry(args: CodexCatalogEntry): Record<string, unknown> {
@@ -39,8 +41,11 @@ function codexCatalogEntry(args: CodexCatalogEntry): Record<string, unknown> {
39
41
  slug: args.slug,
40
42
  display_name: args.displayName,
41
43
  description: args.description,
42
- default_reasoning_level: null,
43
- supported_reasoning_levels: [],
44
+ default_reasoning_level: args.defaultReasoningLevel ?? null,
45
+ supported_reasoning_levels: (args.reasoningLevels ?? []).map((effort) => ({
46
+ effort,
47
+ description: `${effort} thinking`,
48
+ })),
44
49
  shell_type: "shell_command",
45
50
  visibility: "list",
46
51
  supported_in_api: true,
@@ -80,15 +85,17 @@ function codexCatalogEntry(args: CodexCatalogEntry): Record<string, unknown> {
80
85
  };
81
86
  }
82
87
 
83
- /* `dari/routing` stays first (the Codex launcher patches it); each enabled
84
- model follows as `dari/model/<id>` so Codex's /model menu can switch to it. */
88
+ /* `dari/routing` comes first while the router may autoroute (the Codex
89
+ launcher patches it); each enabled model follows as `dari/model/<id>` so
90
+ Codex's /model menu can switch to it. The `dari` launchers build every
91
+ agent's model menu from this list. */
85
92
  export function codexModelsResponse(
86
- router: Pick<ResolvedRouter, "enabled_models" | "model_configs" | "allow_long_context">,
93
+ router: Pick<ResolvedRouter, "enabled_models" | "model_configs" | "allow_long_context" | "autorouting_enabled">,
87
94
  imageInputFor: (model: string) => boolean
88
95
  ): Record<string, unknown> {
89
96
  return {
90
97
  models: [
91
- codexCatalogEntry({
98
+ ...(router.autorouting_enabled === false ? [] : [codexCatalogEntry({
92
99
  slug: CANONICAL_ROUTER_MODEL,
93
100
  displayName: "Dari Routing",
94
101
  description: "Routes Codex HTTP Responses requests through the configured Dari model router.",
@@ -96,7 +103,7 @@ export function codexModelsResponse(
96
103
  contextWindow: codexRouterContextWindow(router),
97
104
  hostedWebSearch: codexRouterSupportsHostedWebSearch(router),
98
105
  imageInput: router.enabled_models.some(imageInputFor),
99
- }),
106
+ })]),
100
107
  ...router.enabled_models.map((model, index) =>
101
108
  codexCatalogEntry({
102
109
  slug: explicitModelSlug(model),
@@ -106,6 +113,10 @@ export function codexModelsResponse(
106
113
  contextWindow: codexRouterContextWindow({ ...router, enabled_models: [model] }),
107
114
  hostedWebSearch: codexRouterSupportsHostedWebSearch({ ...router, enabled_models: [model] }),
108
115
  imageInput: imageInputFor(model),
116
+ reasoningLevels: router.model_configs?.[model]?.supported_thinking_levels
117
+ ?.map((effort) => effort === "off" ? "none" : effort),
118
+ defaultReasoningLevel: router.model_configs?.[model]?.default_thinking_level === "off"
119
+ ? "none" : router.model_configs?.[model]?.default_thinking_level,
109
120
  })
110
121
  ),
111
122
  ],
@@ -45,6 +45,10 @@ export type GatewayPlatform = {
45
45
  writePrefixState(write: PrefixStateWrite): Promise<void>;
46
46
  /** Set or clear a conversation's prefetched routing lease. */
47
47
  writePendingLease(write: PendingLeaseWrite): Promise<void>;
48
+ /** The pair a session pinned on a session-pinning router, or null. */
49
+ readSessionPin(request: SessionPinKey): Promise<SessionPin | null>;
50
+ /** Pin a session to a pair, replacing any earlier pin. */
51
+ writeSessionPin(write: SessionPinKey & SessionPin): Promise<void>;
48
52
  /**
49
53
  * Hold an exhausted personal subscription until it resets. Resolves whether
50
54
  * another connected subscription for the same provider can serve a retry.
@@ -104,6 +108,17 @@ export type PendingLeaseWrite = {
104
108
  } | null;
105
109
  };
106
110
 
111
+ // A main agent and each of its subagents have their own pin key.
112
+ export type SessionPinKey = {
113
+ routerId: string;
114
+ pinKey: string;
115
+ };
116
+
117
+ export type SessionPin = {
118
+ model: string;
119
+ reasoningEffort: ReasoningEffort;
120
+ };
121
+
107
122
  export type SubscriptionHold = {
108
123
  userId: string;
109
124
  credentialId: string | undefined;
@@ -146,8 +161,6 @@ export type RequestSummary = {
146
161
  selected_reasoning_effort: ReasoningEffort | null;
147
162
  next_model: string | null;
148
163
  next_reasoning_effort: ReasoningEffort | null;
149
- fallback_model: string | null;
150
- fallback_reasoning_effort: ReasoningEffort | null;
151
164
  provider_key_source: ProviderKeySource | null;
152
165
  message_count: number;
153
166
  tool_count: number;
@@ -184,6 +197,7 @@ export type OperationalErrorCode =
184
197
  | "routing_report_assembly_failed"
185
198
  | "routing_report_barrier_failed"
186
199
  | "server_force_stop_failed"
200
+ | "session_pin_write_failed"
187
201
  | "stream_report_finalization_failed"
188
202
  | "subscription_unavailable_ingest_failed";
189
203
 
@@ -572,15 +572,8 @@ function reasoningControls(value: unknown): InteractionRequest["reasoning"] {
572
572
  if (value === undefined || value === null) return undefined;
573
573
  if (!isRecord(value)) throw new HttpError(400, "reasoning must be an object.", "invalid_request_error", "reasoning");
574
574
  const normalizedEffort = normalizedReasoningEffort(value.effort, "reasoning.effort");
575
- const summary = value.summary;
576
- if (summary !== undefined) {
577
- throw new HttpError(
578
- 400,
579
- "reasoning.summary is not supported by this router MVP.",
580
- "invalid_request_error",
581
- "reasoning.summary"
582
- );
583
- }
575
+ // Summaries are display-only: clients such as Muse Code ask for them by
576
+ // default, and the router serves the turn without them.
584
577
  return normalizedEffort === undefined ? undefined : { effort: normalizedEffort };
585
578
  }
586
579
 
@@ -2,7 +2,7 @@ import { parseDataImageUrl } from "../../interaction/image.js";
2
2
  import { encodeProviderContinuationState } from "../../interaction/provider_continuation_state.js";
3
3
  import { encodePortableReasoningState } from "../../interaction/portable_reasoning_state.js";
4
4
  import type { InteractionOutput } from "../../interaction/types.js";
5
- import type { RoutingDecision } from "@mupt-ai/dari-router/policy-engine";
5
+ import type { ServedDecision } from "../../types.js";
6
6
 
7
7
  export function anthropicMessageId(requestId: string): string {
8
8
  return `msg_${requestId.replace(/[^A-Za-z0-9_-]/g, "_")}`.slice(0, 64);
@@ -99,7 +99,7 @@ export function anthropicUsage(output: Pick<InteractionOutput, "usage">): Record
99
99
  }
100
100
 
101
101
  export function anthropicRouting(
102
- decision: RoutingDecision,
102
+ decision: ServedDecision,
103
103
  requestId: string,
104
104
  conversationId?: string
105
105
  ): Record<string, unknown> {
@@ -113,6 +113,7 @@ export function anthropicRouting(
113
113
  ...(decision.leaseTurnsRemaining === undefined
114
114
  ? {}
115
115
  : { lease_turns_remaining: decision.leaseTurnsRemaining }),
116
+ ...(decision.sessionPinned === true ? { session_pinned: true } : {}),
116
117
  };
117
118
  }
118
119
 
@@ -6,7 +6,7 @@ import {
6
6
  openAIResponsesTerminal,
7
7
  openAIResponsesUsage,
8
8
  } from "./openai_responses_output.js";
9
- import { piMessageToInteractionOutput } from "../../providers/pi_output.js";
9
+ import { piMessageToInteractionOutput, textSignature } from "../../providers/pi_output.js";
10
10
  import type {
11
11
  InteractionOutput,
12
12
  InteractionOutputItem,
@@ -119,6 +119,7 @@ class ResponsesAccumulator {
119
119
  private readonly addedIndexes = new Set<number>();
120
120
  private readonly completedIndexes = new Set<number>();
121
121
  private readonly messageIds = new Map<number, string>();
122
+ private readonly messagePhases = new Map<number, "commentary" | "final_answer">();
122
123
  private readonly reasoningIds = new Map<number, string>();
123
124
  private readonly messagePartStarted = new Set<number>();
124
125
  private readonly reasoningPartStarted = new Set<number>();
@@ -144,6 +145,7 @@ class ResponsesAccumulator {
144
145
  const index = this.indexFor(event);
145
146
  this.activate(index, send);
146
147
  if (type === "thinking_start") this.ensureReasoning(index, send);
148
+ if (type === "text_start") this.rememberMessageSignature(event, index);
147
149
  return;
148
150
  }
149
151
  if (type === "text_delta" && typeof event.delta === "string" && event.delta) {
@@ -330,17 +332,29 @@ class ResponsesAccumulator {
330
332
  return openAIResponsesUsage(this.finalOutput ?? this.baseOutput());
331
333
  }
332
334
 
335
+ // Pi signs a Responses text block with its message id and phase at
336
+ // text_start, so the item is announced with the phase it ends with.
337
+ private rememberMessageSignature(event: Record<string, unknown>, index: number): void {
338
+ const content = isRecord(event.partial) ? event.partial.content : undefined;
339
+ const block = Array.isArray(content) ? content[event.contentIndex as number] : undefined;
340
+ const signature = isRecord(block) ? textSignature(block.textSignature) : null;
341
+ if (signature?.id && !this.messageIds.has(index)) this.messageIds.set(index, signature.id);
342
+ if (signature?.phase) this.messagePhases.set(index, signature.phase);
343
+ }
344
+
333
345
  private ensureMessage(index: number, send: Send): Extract<InteractionOutputItem, { type: "message" }> {
334
346
  const existing = this.items.get(index);
335
347
  if (existing?.type === "message") return existing;
336
348
  const id = this.messageIds.get(index) ?? streamedItemId("msg", this.responseId, index);
337
349
  this.messageIds.set(index, id);
350
+ const phase = this.messagePhases.get(index);
338
351
  const message: Extract<InteractionOutputItem, { type: "message" }> = {
339
352
  type: "message",
340
353
  id,
341
354
  role: "assistant",
342
355
  content: [{ type: "text", text: "" }],
343
356
  contentShape: "parts",
357
+ ...(phase ? { phase } : {}),
344
358
  source: this.args.source,
345
359
  };
346
360
  this.items.set(index, message);
@@ -353,6 +367,7 @@ class ResponsesAccumulator {
353
367
  id,
354
368
  status: "in_progress",
355
369
  content: [],
370
+ ...(phase ? { phase } : {}),
356
371
  },
357
372
  });
358
373
  this.sendMessagePartAdded(index, id, send);
@@ -420,6 +435,7 @@ class ResponsesAccumulator {
420
435
  id: serialized.id,
421
436
  status: "in_progress",
422
437
  content: [],
438
+ ...(item.phase ? { phase: item.phase } : {}),
423
439
  },
424
440
  });
425
441
  } else {