@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
package/README.md CHANGED
@@ -46,10 +46,10 @@ bun install
46
46
  bun run start
47
47
  ```
48
48
 
49
- Or with Docker, from the repository root:
49
+ Or with Docker, from this directory (in the monorepo, run `docker build -t dari-gateway dari-gateway` from the repository root):
50
50
 
51
51
  ```bash
52
- docker build -f dari-gateway/Dockerfile -t dari-gateway .
52
+ docker build -t dari-gateway .
53
53
  docker run -p 8080:8080 \
54
54
  -e DARI_GATEWAY_KEYS -e ANTHROPIC_API_KEY \
55
55
  -v "$PWD/dari-gateway.yaml:/config/dari-gateway.yaml" \
@@ -64,6 +64,7 @@ docker run -p 8080:8080 \
64
64
  | `DARI_GATEWAY_DB` | SQLite database for subscriptions and conversation state. Default `dari-gateway.db`. It stores subscription tokens in plaintext; protect it like a secret. |
65
65
  | `DARI_AUTO_ROUTING_API_KEY` | Optional. Select among a router's models with Dari Auto Routing. |
66
66
  | `PORT` | Listen port. Default `8080`. |
67
+ | `DARI_GATEWAY_HOST` | Listen address. Default `127.0.0.1`, so only this machine can connect. The container image sets `0.0.0.0`; set it yourself to expose a process on your network. |
67
68
  | Provider keys | Each provider's standard variable, such as `ANTHROPIC_API_KEY`, `OPENAI_API_KEY`, `META_API_KEY`, or `OPENROUTER_API_KEY`. |
68
69
 
69
70
  ## Configure
@@ -75,8 +76,8 @@ routers:
75
76
  - anthropic/claude-sonnet-5-5
76
77
  - id: openai/gpt-5.5
77
78
  thinking: [medium, high]
78
- fallback: true # retry the next model when a provider fails
79
79
  subscriptions: true # prefer connected subscriptions over API keys
80
+ sessionPinning: false # route each session and subagent once, then keep its model
80
81
  local: # serves /local/v1/...
81
82
  models: [local/qwen3-coder]
82
83
 
@@ -106,7 +107,7 @@ In gateway mode the CLI makes no calls to Dari's hosted API. `dari credentials s
106
107
 
107
108
  ## Embed
108
109
 
109
- Deployments with their own storage and accounting compose the runtime with a `GatewayPlatform` (router resolution, conversation state, subscription holds, request records, error reporting) and a selector backend:
110
+ Deployments with their own storage and accounting compose the runtime with a `GatewayPlatform` (router resolution, conversation state, session pins, subscription holds, request records, error reporting) and a selector backend:
110
111
 
111
112
  ```ts
112
113
  import { createPrioritySelectorBackend, createRequestHandler } from "@mupt-ai/dari-gateway";
@@ -120,19 +121,26 @@ The root entry needs no Bun APIs, so any runtime with web-standard `fetch`, `Req
120
121
  Platforms can also:
121
122
 
122
123
  - implement `resolveProviderCredential({ provider, model, routerId, organizationId, apiKey, clientHarness? })` to supply a per-request `{ apiKey, baseUrl?, headers?, subscription? }`, such as a tenant's own key, in place of the router's resolved key. With `subscription` (`"openai_codex"`, `"anthropic_claude_code"`, or `"meta_muse"`), `apiKey` is that plan's access token: Codex's `auth.json` `tokens.access_token`, the token `claude setup-token` prints, or the Model API key a Meta sign-in mints. The platform keeps it fresh. Strict routers serve these supplied plans, but never subscriptions the gateway resolved itself. A plan serves only its own provider's models; a Claude Code plan serves only Claude Code clients, and a Muse Code plan only Muse Code clients (recognized by their `x-client-id`);
123
- - resolve routers with `strict: true` to serve only the requested `dari/model/<id>` at the requested thinking level, with no selector, lease, fallback, or personal subscription. Set `strict_providers: ["openai"]` on the resolved router to permit any bundled-catalog model from that provider without enumerating `enabled_models`. Use `dari/model/openai/gpt-5.5`; bare IDs and unknown models remain refused. This grant is ignored on non-strict routers and does not discover uncatalogued models;
124
+ - resolve routers with `strict: true` to serve only the requested `dari/model/<id>` at the requested thinking level, with no selector, lease, or personal subscription. Set `strict_providers: ["openai"]` on the resolved router to permit any bundled-catalog model from that provider without enumerating `enabled_models`. Use `dari/model/openai/gpt-5.5`; bare IDs and unknown models remain refused. This grant is ignored on non-strict routers and does not discover uncatalogued models;
125
+ - resolve routers with `session_pinning: true` to route each session once and serve that pair for the rest of the session. Each subagent (Claude Code agent id, Codex subagent thread, OpenCode child session) is routed once on its own. Pins are kept through `readSessionPin` and `writeSessionPin`, keyed by router and an opaque pin key; background requests and `dari/model/<id>` requests never use them;
124
126
  - read exact usage from `recordRequest`: the summary carries input, output, cache-read, and cache-write tokens and `total_cost_usd`, preferring the provider's billed cost (`cost_source: "provider"`, from OpenRouter and Vercel AI Gateway). Failed and aborted requests are recorded too;
125
127
  - attribute cost with `X-Dari-Tags: run=r1,trial=3`, which is parsed into `RequestRecord.tags` and the `recordUsage` payload's `tags`.
126
128
 
127
129
  ## Development
128
130
 
129
131
  ```bash
130
- (cd ../dari-router && bun install --frozen-lockfile && bun run build)
131
132
  bun install --frozen-lockfile
132
133
  bun run typecheck
133
134
  bun test
134
135
  ```
135
136
 
137
+ This package depends on the published `@mupt-ai/dari-router`. To test against a local router checkout inside the monorepo, build it and link it after installing:
138
+
139
+ ```bash
140
+ (cd ../dari-router && bun install --frozen-lockfile && bun run build)
141
+ ../scripts/link-gateway-router.sh
142
+ ```
143
+
136
144
  Tests make no provider or network calls.
137
145
 
138
146
  ## License
@@ -9,11 +9,12 @@ routers:
9
9
  - anthropic/claude-sonnet-5-5
10
10
  - id: openai/gpt-5.5
11
11
  thinking: [medium, high]
12
- # Retry the next model when a provider fails (default true).
13
- fallback: true
14
12
  # Serve with connected Claude Code / ChatGPT subscriptions before API
15
13
  # keys (default true).
16
14
  subscriptions: true
15
+ # Route each session's first turn, then keep that model for the whole
16
+ # session; each subagent is routed once on its own (default false).
17
+ sessionPinning: false
17
18
 
18
19
  local:
19
20
  models:
package/dist/http/auth.js CHANGED
@@ -4,7 +4,7 @@ export function bearerToken(headers) {
4
4
  const value = headers.get("authorization") ?? "";
5
5
  const [scheme, token] = value.split(/\s+/, 2);
6
6
  if (scheme?.toLowerCase() !== "bearer" || !token) {
7
- throw new HttpError(401, "Missing or invalid routing API key.", "authentication_error");
7
+ throw new HttpError(401, "Missing or invalid API key.", "authentication_error");
8
8
  }
9
9
  return publicRoutingToken(token);
10
10
  }
@@ -14,7 +14,7 @@ export function anthropicToken(headers) {
14
14
  return bearerToken(headers);
15
15
  const apiKey = headers.get("x-api-key");
16
16
  if (!apiKey) {
17
- throw new HttpError(401, "Missing or invalid routing API key.", "authentication_error");
17
+ throw new HttpError(401, "Missing or invalid API key.", "authentication_error");
18
18
  }
19
19
  return publicRoutingToken(apiKey);
20
20
  }
@@ -3,4 +3,4 @@ export type Http2RoutingServer = {
3
3
  readonly port: number;
4
4
  stop(force?: boolean): Promise<void>;
5
5
  };
6
- export declare function startHttp2Server(port: number, handler: Http2RequestHandler): Http2RoutingServer;
6
+ export declare function startHttp2Server(port: number, handler: Http2RequestHandler, hostname?: string): Http2RoutingServer;
@@ -8,7 +8,7 @@ const HTTP2_FORBIDDEN_RESPONSE_HEADERS = new Set([
8
8
  "transfer-encoding",
9
9
  "upgrade",
10
10
  ]);
11
- export function startHttp2Server(port, handler) {
11
+ export function startHttp2Server(port, handler, hostname = "0.0.0.0") {
12
12
  const sessions = new Set();
13
13
  let closePromise;
14
14
  const server = createServer((request, response) => {
@@ -18,10 +18,10 @@ export function startHttp2Server(port, handler) {
18
18
  sessions.add(session);
19
19
  session.once("close", () => sessions.delete(session));
20
20
  });
21
- server.listen(port, "0.0.0.0");
21
+ server.listen(port, hostname);
22
22
  const address = server.address();
23
23
  if (address === null || typeof address === "string") {
24
- throw new Error("HTTP/2 routing server did not bind to a TCP port.");
24
+ throw new Error("HTTP/2 gateway server did not bind to a TCP port.");
25
25
  }
26
26
  return {
27
27
  port: address.port,
@@ -3,6 +3,7 @@ type StoppableServer = {
3
3
  };
4
4
  export declare function startServer(listenPort: number, handler: (request: Request) => Promise<Response>, options?: {
5
5
  http2?: boolean;
6
+ hostname?: string;
6
7
  }): import("./http2_server.js").Http2RoutingServer | Bun.Server<undefined>;
7
8
  /**
8
9
  * On SIGTERM/SIGINT: stop accepting work, let active requests finish, drain
@@ -9,13 +9,14 @@ const FORCE_STOP_SETTLE_TIMEOUT_MS = 250;
9
9
  const SHUTDOWN_WORK_TIMEOUT_MS = 7_500;
10
10
  export function startServer(listenPort, handler, options = {}) {
11
11
  if (options.http2) {
12
- return startHttp2Server(listenPort, handler);
12
+ return startHttp2Server(listenPort, handler, options.hostname);
13
13
  }
14
14
  // The selector runs before the first response byte, and routed models can
15
15
  // remain silent while reasoning. Disable Bun's default 10s idle timeout;
16
16
  // clients and infrastructure still bound total request lifetime.
17
17
  return Bun.serve({
18
18
  port: listenPort,
19
+ hostname: options.hostname,
19
20
  fetch: handler,
20
21
  idleTimeout: 0,
21
22
  });
@@ -31,14 +32,14 @@ export function installShutdownHandlers(server, closeClients) {
31
32
  return;
32
33
  shuttingDown = true;
33
34
  const workDeadlineAtMs = performance.now() + SHUTDOWN_WORK_TIMEOUT_MS;
34
- console.info("routing server shutting down", JSON.stringify({ signal }));
35
+ console.info("gateway server shutting down", JSON.stringify({ signal }));
35
36
  try {
36
37
  await stopServerForShutdown(server);
37
38
  }
38
39
  finally {
39
40
  const remaining = await drainBackgroundTelemetryFor(Math.max(0, workDeadlineAtMs - performance.now()));
40
41
  if (remaining > 0) {
41
- console.warn("routing server background drain timed out", JSON.stringify({ remaining_task_count: remaining }));
42
+ console.warn("gateway server background drain timed out", JSON.stringify({ remaining_task_count: remaining }));
42
43
  captureOperationalError(new Error(`Routing shutdown abandoned ${remaining} background task(s).`), { code: "background_drain_timeout" });
43
44
  }
44
45
  await closeClients();
@@ -73,6 +74,6 @@ function stopWithin(server, force, timeoutMs) {
73
74
  ]);
74
75
  }
75
76
  function reportServerStopFailure(error) {
76
- console.warn("routing server shutdown failed", error instanceof Error ? error.name : "UnknownError");
77
+ console.warn("gateway server shutdown failed", error instanceof Error ? error.name : "UnknownError");
77
78
  captureOperationalError(error, { code: "server_force_stop_failed" });
78
79
  }
@@ -4,7 +4,7 @@ import { captureRequestError } from "../observability/errors.js";
4
4
  import { chatCompletionResponse, chatCompletionsSseStream } from "../protocol/output/chat_completions.js";
5
5
  import { chatCompletionsToInteraction } from "../protocol/input/chat_completions.js";
6
6
  import { handleRoutedCompletion } from "../runtime/routed_completion.js";
7
- import { chatCompletionsConversationIdentity, openCodeRequestContext, } from "../runtime/harness_identity/index.js";
7
+ import { chatCompletionsConversationIdentity, openCodeRequestContext, piRequestContext, } from "../runtime/harness_identity/index.js";
8
8
  import { parseJsonRequest } from "./json.js";
9
9
  import { museCodeClientId } from "./muse_code.js";
10
10
  export async function handleChatCompletions(request, routerId, selectorBackend) {
@@ -12,7 +12,7 @@ export async function handleChatCompletions(request, routerId, selectorBackend)
12
12
  const apiKey = bearerToken(request.headers);
13
13
  const payload = await parseJsonRequest(request);
14
14
  const interaction = chatCompletionsToInteraction({ ...payload, source_protocol: "openai_chat" });
15
- const agentContext = openCodeRequestContext(request.headers);
15
+ const agentContext = piRequestContext(request.headers) ?? openCodeRequestContext(request.headers);
16
16
  const museClientId = museCodeClientId(request.headers);
17
17
  return await handleRoutedCompletion({
18
18
  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";
@@ -27,8 +28,9 @@ export async function handleResponses(request, routerId, selectorBackend) {
27
28
  // providers. That preference describes a direct model, not a heterogeneous
28
29
  // router, so let Dari select the reasoning effort for verified Codex turns.
29
30
  // Parsing above still validates malformed Responses reasoning controls.
30
- if (harnessConversationIdentity !== undefined)
31
+ if (harnessConversationIdentity !== undefined && explicitModelId(payload.model) === null) {
31
32
  payload.reasoning = undefined;
33
+ }
32
34
  return await handleRoutedCompletion({
33
35
  request,
34
36
  routerId,
@@ -1,2 +1,2 @@
1
1
  import type { ResolvedRouter } from "../types.js";
2
- export declare function codexModelsResponse(router: Pick<ResolvedRouter, "enabled_models" | "model_configs" | "allow_long_context">, imageInputFor: (model: string) => boolean): Record<string, unknown>;
2
+ export declare function codexModelsResponse(router: Pick<ResolvedRouter, "enabled_models" | "model_configs" | "allow_long_context" | "autorouting_enabled">, imageInputFor: (model: string) => boolean): Record<string, unknown>;
@@ -22,8 +22,11 @@ function codexCatalogEntry(args) {
22
22
  slug: args.slug,
23
23
  display_name: args.displayName,
24
24
  description: args.description,
25
- default_reasoning_level: null,
26
- supported_reasoning_levels: [],
25
+ default_reasoning_level: args.defaultReasoningLevel ?? null,
26
+ supported_reasoning_levels: (args.reasoningLevels ?? []).map((effort) => ({
27
+ effort,
28
+ description: `${effort} thinking`,
29
+ })),
27
30
  shell_type: "shell_command",
28
31
  visibility: "list",
29
32
  supported_in_api: true,
@@ -61,20 +64,22 @@ function codexCatalogEntry(args) {
61
64
  multi_agent_version: null,
62
65
  };
63
66
  }
64
- /* `dari/routing` stays first (the Codex launcher patches it); each enabled
65
- model follows as `dari/model/<id>` so Codex's /model menu can switch to it. */
67
+ /* `dari/routing` comes first while the router may autoroute (the Codex
68
+ launcher patches it); each enabled model follows as `dari/model/<id>` so
69
+ Codex's /model menu can switch to it. The `dari` launchers build every
70
+ agent's model menu from this list. */
66
71
  export function codexModelsResponse(router, imageInputFor) {
67
72
  return {
68
73
  models: [
69
- codexCatalogEntry({
70
- slug: CANONICAL_ROUTER_MODEL,
71
- displayName: "Dari Routing",
72
- description: "Routes Codex HTTP Responses requests through the configured Dari model router.",
73
- priority: 0,
74
- contextWindow: codexRouterContextWindow(router),
75
- hostedWebSearch: codexRouterSupportsHostedWebSearch(router),
76
- imageInput: router.enabled_models.some(imageInputFor),
77
- }),
74
+ ...(router.autorouting_enabled === false ? [] : [codexCatalogEntry({
75
+ slug: CANONICAL_ROUTER_MODEL,
76
+ displayName: "Dari Routing",
77
+ description: "Routes Codex HTTP Responses requests through the configured Dari model router.",
78
+ priority: 0,
79
+ contextWindow: codexRouterContextWindow(router),
80
+ hostedWebSearch: codexRouterSupportsHostedWebSearch(router),
81
+ imageInput: router.enabled_models.some(imageInputFor),
82
+ })]),
78
83
  ...router.enabled_models.map((model, index) => codexCatalogEntry({
79
84
  slug: explicitModelSlug(model),
80
85
  displayName: model,
@@ -83,6 +88,10 @@ export function codexModelsResponse(router, imageInputFor) {
83
88
  contextWindow: codexRouterContextWindow({ ...router, enabled_models: [model] }),
84
89
  hostedWebSearch: codexRouterSupportsHostedWebSearch({ ...router, enabled_models: [model] }),
85
90
  imageInput: imageInputFor(model),
91
+ reasoningLevels: router.model_configs?.[model]?.supported_thinking_levels
92
+ ?.map((effort) => effort === "off" ? "none" : effort),
93
+ defaultReasoningLevel: router.model_configs?.[model]?.default_thinking_level === "off"
94
+ ? "none" : router.model_configs?.[model]?.default_thinking_level,
86
95
  })),
87
96
  ],
88
97
  };
@@ -33,6 +33,10 @@ export type GatewayPlatform = {
33
33
  writePrefixState(write: PrefixStateWrite): Promise<void>;
34
34
  /** Set or clear a conversation's prefetched routing lease. */
35
35
  writePendingLease(write: PendingLeaseWrite): Promise<void>;
36
+ /** The pair a session pinned on a session-pinning router, or null. */
37
+ readSessionPin(request: SessionPinKey): Promise<SessionPin | null>;
38
+ /** Pin a session to a pair, replacing any earlier pin. */
39
+ writeSessionPin(write: SessionPinKey & SessionPin): Promise<void>;
36
40
  /**
37
41
  * Hold an exhausted personal subscription until it resets. Resolves whether
38
42
  * another connected subscription for the same provider can serve a retry.
@@ -84,6 +88,14 @@ export type PendingLeaseWrite = {
84
88
  output_text: string | null;
85
89
  } | null;
86
90
  };
91
+ export type SessionPinKey = {
92
+ routerId: string;
93
+ pinKey: string;
94
+ };
95
+ export type SessionPin = {
96
+ model: string;
97
+ reasoningEffort: ReasoningEffort;
98
+ };
87
99
  export type SubscriptionHold = {
88
100
  userId: string;
89
101
  credentialId: string | undefined;
@@ -117,8 +129,6 @@ export type RequestSummary = {
117
129
  selected_reasoning_effort: ReasoningEffort | null;
118
130
  next_model: string | null;
119
131
  next_reasoning_effort: ReasoningEffort | null;
120
- fallback_model: string | null;
121
- fallback_reasoning_effort: ReasoningEffort | null;
122
132
  provider_key_source: ProviderKeySource | null;
123
133
  message_count: number;
124
134
  tool_count: number;
@@ -145,7 +155,7 @@ export type RequestErrorContext = {
145
155
  sourceProtocol: SourceProtocol | "openai_models" | "anthropic_models";
146
156
  status: number;
147
157
  };
148
- export type OperationalErrorCode = "background_drain_timeout" | "billing_usage_report_failed" | "routing_report_assembly_failed" | "routing_report_barrier_failed" | "server_force_stop_failed" | "stream_report_finalization_failed" | "subscription_unavailable_ingest_failed";
158
+ export type OperationalErrorCode = "background_drain_timeout" | "billing_usage_report_failed" | "routing_report_assembly_failed" | "routing_report_barrier_failed" | "server_force_stop_failed" | "session_pin_write_failed" | "stream_report_finalization_failed" | "subscription_unavailable_ingest_failed";
149
159
  export type OperationalErrorContext = {
150
160
  code: OperationalErrorCode;
151
161
  requestId?: string;
@@ -499,10 +499,8 @@ function reasoningControls(value) {
499
499
  if (!isRecord(value))
500
500
  throw new HttpError(400, "reasoning must be an object.", "invalid_request_error", "reasoning");
501
501
  const normalizedEffort = normalizedReasoningEffort(value.effort, "reasoning.effort");
502
- const summary = value.summary;
503
- if (summary !== undefined) {
504
- throw new HttpError(400, "reasoning.summary is not supported by this router MVP.", "invalid_request_error", "reasoning.summary");
505
- }
502
+ // Summaries are display-only: clients such as Muse Code ask for them by
503
+ // default, and the router serves the turn without them.
506
504
  return normalizedEffort === undefined ? undefined : { effort: normalizedEffort };
507
505
  }
508
506
  function normalizedReasoningEffort(value, param) {
@@ -1,5 +1,5 @@
1
1
  import type { InteractionOutput } from "../../interaction/types.js";
2
- import type { RoutingDecision } from "@mupt-ai/dari-router/policy-engine";
2
+ import type { ServedDecision } from "../../types.js";
3
3
  export declare function anthropicMessageId(requestId: string): string;
4
4
  export declare function anthropicContent(output: InteractionOutput): Array<Record<string, unknown>>;
5
5
  export declare function anthropicReasoningBlock(item: Extract<InteractionOutput["items"][number], {
@@ -7,4 +7,4 @@ export declare function anthropicReasoningBlock(item: Extract<InteractionOutput[
7
7
  }>): Record<string, unknown>;
8
8
  export declare function anthropicStopReason(output: Pick<InteractionOutput, "stopReason">): string;
9
9
  export declare function anthropicUsage(output: Pick<InteractionOutput, "usage">): Record<string, unknown>;
10
- export declare function anthropicRouting(decision: RoutingDecision, requestId: string, conversationId?: string): Record<string, unknown>;
10
+ export declare function anthropicRouting(decision: ServedDecision, requestId: string, conversationId?: string): Record<string, unknown>;
@@ -103,6 +103,7 @@ export function anthropicRouting(decision, requestId, conversationId) {
103
103
  ...(decision.leaseTurnsRemaining === undefined
104
104
  ? {}
105
105
  : { lease_turns_remaining: decision.leaseTurnsRemaining }),
106
+ ...(decision.sessionPinned === true ? { session_pinned: true } : {}),
106
107
  };
107
108
  }
108
109
  function toolInput(kind, value) {
@@ -1,5 +1,5 @@
1
1
  import { openAIResponsesOutputItem, openAIResponsesOutputItems, openAIResponsesResponseId, openAIResponsesRouting, openAIResponsesTerminal, openAIResponsesUsage, } from "./openai_responses_output.js";
2
- import { piMessageToInteractionOutput } from "../../providers/pi_output.js";
2
+ import { piMessageToInteractionOutput, textSignature } from "../../providers/pi_output.js";
3
3
  import { isProviderFailureMessage, providerFailureFromEvent, providerFailureMessage, } from "../../runtime/provider_stream_outcome.js";
4
4
  import { sseStream } from "./sse_stream.js";
5
5
  export function openAIResponsesSseStream(args) {
@@ -78,6 +78,7 @@ class ResponsesAccumulator {
78
78
  addedIndexes = new Set();
79
79
  completedIndexes = new Set();
80
80
  messageIds = new Map();
81
+ messagePhases = new Map();
81
82
  reasoningIds = new Map();
82
83
  messagePartStarted = new Set();
83
84
  reasoningPartStarted = new Set();
@@ -102,6 +103,8 @@ class ResponsesAccumulator {
102
103
  this.activate(index, send);
103
104
  if (type === "thinking_start")
104
105
  this.ensureReasoning(index, send);
106
+ if (type === "text_start")
107
+ this.rememberMessageSignature(event, index);
105
108
  return;
106
109
  }
107
110
  if (type === "text_delta" && typeof event.delta === "string" && event.delta) {
@@ -273,18 +276,31 @@ class ResponsesAccumulator {
273
276
  currentUsage() {
274
277
  return openAIResponsesUsage(this.finalOutput ?? this.baseOutput());
275
278
  }
279
+ // Pi signs a Responses text block with its message id and phase at
280
+ // text_start, so the item is announced with the phase it ends with.
281
+ rememberMessageSignature(event, index) {
282
+ const content = isRecord(event.partial) ? event.partial.content : undefined;
283
+ const block = Array.isArray(content) ? content[event.contentIndex] : undefined;
284
+ const signature = isRecord(block) ? textSignature(block.textSignature) : null;
285
+ if (signature?.id && !this.messageIds.has(index))
286
+ this.messageIds.set(index, signature.id);
287
+ if (signature?.phase)
288
+ this.messagePhases.set(index, signature.phase);
289
+ }
276
290
  ensureMessage(index, send) {
277
291
  const existing = this.items.get(index);
278
292
  if (existing?.type === "message")
279
293
  return existing;
280
294
  const id = this.messageIds.get(index) ?? streamedItemId("msg", this.responseId, index);
281
295
  this.messageIds.set(index, id);
296
+ const phase = this.messagePhases.get(index);
282
297
  const message = {
283
298
  type: "message",
284
299
  id,
285
300
  role: "assistant",
286
301
  content: [{ type: "text", text: "" }],
287
302
  contentShape: "parts",
303
+ ...(phase ? { phase } : {}),
288
304
  source: this.args.source,
289
305
  };
290
306
  this.items.set(index, message);
@@ -297,6 +313,7 @@ class ResponsesAccumulator {
297
313
  id,
298
314
  status: "in_progress",
299
315
  content: [],
316
+ ...(phase ? { phase } : {}),
300
317
  },
301
318
  });
302
319
  this.sendMessagePartAdded(index, id, send);
@@ -363,6 +380,7 @@ class ResponsesAccumulator {
363
380
  id: serialized.id,
364
381
  status: "in_progress",
365
382
  content: [],
383
+ ...(item.phase ? { phase: item.phase } : {}),
366
384
  },
367
385
  });
368
386
  }
@@ -7,9 +7,7 @@ import { type ProviderExecution, type StartedCompletion, type StartedStream } fr
7
7
  export type ProviderChoice = {
8
8
  decision: RoutingDecision;
9
9
  execution: ProviderExecution;
10
- role: "primary" | "fallback";
11
10
  };
12
- export type ResolveFallbackChoice = () => Promise<ProviderChoice | null>;
13
11
  export type PreparedStreamExecution = {
14
12
  choice: ProviderChoice;
15
13
  started: StartedStream;
@@ -27,7 +25,6 @@ export declare function executeNonStreaming(args: {
27
25
  interaction: InteractionRequest;
28
26
  primary: ProviderChoice;
29
27
  primaryRetries: number;
30
- resolveFallback: ResolveFallbackChoice;
31
28
  reporter: RoutedRuntimeReporter;
32
29
  protocol: RoutedProtocol;
33
30
  startedCompletion?: StartedCompletion;
@@ -39,7 +36,6 @@ export declare function completeNonStreaming(args: {
39
36
  interaction: InteractionRequest;
40
37
  primary: ProviderChoice;
41
38
  primaryRetries: number;
42
- resolveFallback: ResolveFallbackChoice;
43
39
  reporter: RoutedRuntimeReporter;
44
40
  protocol: RoutedProtocol;
45
41
  startedCompletion?: StartedCompletion;
@@ -51,7 +47,6 @@ export declare function prepareStreamingExecution(args: {
51
47
  interaction: InteractionRequest;
52
48
  primary: ProviderChoice;
53
49
  primaryRetries: number;
54
- resolveFallback: ResolveFallbackChoice;
55
50
  reporter: RoutedRuntimeReporter;
56
51
  startedStream?: StartedStream;
57
52
  initialError?: unknown;
@@ -58,7 +58,7 @@ export async function completeNonStreaming(args) {
58
58
  output,
59
59
  modelCost: args.reporter.modelCost({
60
60
  model: choice.decision.selectedModel, reasoning_effort: choice.decision.reasoningEffort,
61
- role: choice.role, attempt: result.attempt, outcome: "completed", billingOutcome: "completed",
61
+ attempt: result.attempt, outcome: "completed", billingOutcome: "completed",
62
62
  execution: reportedExecution(choice.execution), message: completion.message,
63
63
  }),
64
64
  request: args.interaction,
@@ -121,7 +121,6 @@ async function completeWithRetries(args) {
121
121
  return runProviderAttemptPlan({
122
122
  primary: args.primary,
123
123
  retries: args.primaryRetries,
124
- resolveFallback: args.resolveFallback,
125
124
  primaryInitial: args.startedCompletion,
126
125
  isCustomerCanceled: () => customerAbortRequested(args.clientSignal),
127
126
  canceledFailure: customerCanceledProviderMessage,
@@ -255,7 +254,6 @@ export async function prepareStreamingExecution(args) {
255
254
  const result = await runProviderAttemptPlan({
256
255
  primary: args.primary,
257
256
  retries: args.primaryRetries,
258
- resolveFallback: args.resolveFallback,
259
257
  primaryInitial,
260
258
  isCustomerCanceled: () => customerAbortRequested(args.clientSignal),
261
259
  canceledFailure: customerCanceledProviderMessage,
@@ -307,7 +305,7 @@ async function runStreamSetupAttempt(args) {
307
305
  clientSignal: args.clientSignal,
308
306
  });
309
307
  // Prime before committing so connection failures and prompt rejections
310
- // stay inside the one shared retry/fallback state machine.
308
+ // stay inside the one shared retry state machine.
311
309
  const primed = await primeProviderStream(started.providerStream.events, started.customerAbort);
312
310
  if (primed.outcome === "error") {
313
311
  started.customerAbort.dispose();
@@ -349,7 +347,6 @@ export async function executeStreaming(args) {
349
347
  reporter: args.reporter,
350
348
  protocol: args.protocol,
351
349
  providerLatencyMs: () => args.failedSetupLatencyMs + Date.now() - started.providerStartedMs,
352
- role: args.choice.role,
353
350
  attempt: args.attempt,
354
351
  reportBarrier: args.reportBarrier,
355
352
  delivered: observed.delivered,
@@ -367,7 +364,7 @@ export async function executeStreaming(args) {
367
364
  clientSignal: args.clientSignal,
368
365
  modelCost: (message, failed) => args.reporter.modelCost({
369
366
  model: args.choice.decision.selectedModel, reasoning_effort: args.choice.decision.reasoningEffort,
370
- role: args.choice.role, attempt: args.attempt,
367
+ attempt: args.attempt,
371
368
  outcome: failed ? "terminal_error" : "completed",
372
369
  billingOutcome: failed ? "provider_failed" : "completed",
373
370
  execution: reportedExecution(execution), message,
@@ -396,7 +393,6 @@ function recordProviderAttempt(reporter, choice, attempt, outcome, billingOutcom
396
393
  reporter.recordProviderAttempt({
397
394
  model: choice.decision.selectedModel,
398
395
  reasoning_effort: choice.decision.reasoningEffort,
399
- role: choice.role,
400
396
  attempt,
401
397
  outcome,
402
398
  execution: reportedExecution(choice.execution),
@@ -424,7 +420,8 @@ async function providerFailedAttempt(message, latencyMs, execution, responseCapt
424
420
  }
425
421
  // Retrying an exhausted plan cannot succeed. The platform learns about it
426
422
  // before this request returns so the next resolve already serves the
427
- // user's next subscription, or the router's subscription fallback model.
423
+ // user's next subscription, or the model's API credential when the router
424
+ // allows subscription API fallback for it.
428
425
  const switched = execution.subscriptionUserId !== undefined
429
426
  && await holdSubscription({
430
427
  userId: execution.subscriptionUserId,
@@ -1,2 +1,6 @@
1
1
  import type { InteractionOutput, InteractionRequest, ProviderIdentity } from "../interaction/types.js";
2
2
  export declare function piMessageToInteractionOutput(message: Record<string, unknown>, source: ProviderIdentity, request: Pick<InteractionRequest, "tools">): InteractionOutput;
3
+ export declare function textSignature(value: unknown): {
4
+ id?: string;
5
+ phase?: "commentary" | "final_answer";
6
+ } | null;
@@ -364,7 +364,7 @@ function reasoningText(value, type) {
364
364
  return [];
365
365
  return value.flatMap((entry) => isRecord(entry) && entry.type === type && typeof entry.text === "string" ? [entry.text] : []);
366
366
  }
367
- function textSignature(value) {
367
+ export function textSignature(value) {
368
368
  if (typeof value !== "string" || !value)
369
369
  return null;
370
370
  try {
@@ -31,7 +31,6 @@ export type ProviderAttemptPlanResult<TChoice, TValue> = {
31
31
  type RunProviderAttemptPlanArgs<TChoice, TValue, TInitial> = {
32
32
  primary: TChoice;
33
33
  retries: number;
34
- resolveFallback: () => Promise<TChoice | null>;
35
34
  primaryInitial?: TInitial;
36
35
  isCustomerCanceled: (choice: TChoice) => boolean;
37
36
  canceledFailure: () => Record<string, unknown>;
@@ -48,8 +47,8 @@ type RunProviderAttemptPlanArgs<TChoice, TValue, TInitial> = {
48
47
  };
49
48
  /**
50
49
  * Owns the order-sensitive provider plan shared by completion and stream setup:
51
- * retries within one choice, cancellation fences around asynchronous fallback
52
- * resolution, and one contiguous attempt record for every failed dispatch.
50
+ * retries within the choice, cancellation fences between attempts, and one
51
+ * contiguous attempt record for every failed dispatch.
53
52
  */
54
53
  export declare function runProviderAttemptPlan<TChoice, TValue, TInitial>(args: RunProviderAttemptPlanArgs<TChoice, TValue, TInitial>): Promise<ProviderAttemptPlanResult<TChoice, TValue>>;
55
54
  export {};
@@ -1,8 +1,8 @@
1
1
  import { subscriptionUsageLimitFromFailure } from "./subscription_limits.js";
2
2
  /**
3
3
  * Owns the order-sensitive provider plan shared by completion and stream setup:
4
- * retries within one choice, cancellation fences around asynchronous fallback
5
- * resolution, and one contiguous attempt record for every failed dispatch.
4
+ * retries within the choice, cancellation fences between attempts, and one
5
+ * contiguous attempt record for every failed dispatch.
6
6
  */
7
7
  export async function runProviderAttemptPlan(args) {
8
8
  if (!Number.isSafeInteger(args.retries) || args.retries < 0) {
@@ -11,30 +11,20 @@ export async function runProviderAttemptPlan(args) {
11
11
  const primary = await runChoice(args, args.primary, args.primaryInitial);
12
12
  if (primary.kind !== "provider_failed")
13
13
  return primary;
14
- // The user's next subscription serves a retry of this same choice, which
15
- // beats a fallback model billed elsewhere.
14
+ // The user's next subscription serves the client's retry of this choice, so
15
+ // the failure stands even if the client has already gone.
16
16
  if (subscriptionUsageLimitFromFailure(primary.failure)?.switched === true)
17
17
  return primary;
18
- // A disconnect before or during fallback resolution stays attributed to the
19
- // primary: it is the only choice that was dispatched, and a resolved but
20
- // undispatched fallback must not appear as the serving model.
21
- const canceledAfterPrimary = () => syntheticCancellation(args, {
22
- choice: args.primary,
23
- attempt: primary.attempt + 1,
24
- latencyMs: primary.latencyMs,
25
- });
26
- if (args.isCustomerCanceled(args.primary))
27
- return canceledAfterPrimary();
28
- const fallback = await args.resolveFallback();
29
- if (args.isCustomerCanceled(args.primary))
30
- return canceledAfterPrimary();
31
- if (fallback === null)
32
- return primary;
33
- const fallbackResult = await runChoice(args, fallback, undefined);
34
- return {
35
- ...fallbackResult,
36
- latencyMs: primary.latencyMs + fallbackResult.latencyMs,
37
- };
18
+ // A client that disconnected while the plan settled is a cancellation,
19
+ // attributed to the only choice that was dispatched.
20
+ if (args.isCustomerCanceled(args.primary)) {
21
+ return syntheticCancellation(args, {
22
+ choice: args.primary,
23
+ attempt: primary.attempt + 1,
24
+ latencyMs: primary.latencyMs,
25
+ });
26
+ }
27
+ return primary;
38
28
  }
39
29
  async function runChoice(args, choice, initial) {
40
30
  let latencyMs = 0;