@mupt-ai/dari-gateway 0.3.2 → 0.3.4

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 (70) hide show
  1. package/README.md +10 -11
  2. package/dari-gateway.example.yaml +1 -1
  3. package/dist/context/interaction_fingerprint.js +10 -1
  4. package/dist/context/interaction_fingerprint_format.d.ts +4 -0
  5. package/dist/context/interaction_fingerprint_format.js +3 -0
  6. package/dist/context/provider_prompt_accounting.js +52 -4
  7. package/dist/context/routing_context.d.ts +6 -1
  8. package/dist/context/routing_context.js +40 -4
  9. package/dist/ingress/anthropic_messages.js +12 -0
  10. package/dist/ingress/codex.js +12 -7
  11. package/dist/interaction/capabilities.d.ts +1 -0
  12. package/dist/interaction/capabilities.js +6 -0
  13. package/dist/interaction/document.d.ts +2 -0
  14. package/dist/interaction/document.js +8 -0
  15. package/dist/interaction/types.d.ts +7 -1
  16. package/dist/observability/errors.js +19 -0
  17. package/dist/platform/platform.d.ts +14 -0
  18. package/dist/protocol/input/anthropic_messages.js +74 -1
  19. package/dist/protocol/input/chat_completions.js +6 -0
  20. package/dist/protocol/input/chat_completions_validation.js +8 -0
  21. package/dist/protocol/input/openai_responses.js +8 -0
  22. package/dist/protocol/output/anthropic_messages_output.js +7 -0
  23. package/dist/providers/key_check.d.ts +31 -0
  24. package/dist/providers/key_check.js +54 -0
  25. package/dist/providers/model_capabilities.d.ts +2 -0
  26. package/dist/providers/model_capabilities.js +15 -0
  27. package/dist/providers/pi_context.js +13 -5
  28. package/dist/runtime/harness_identity/conversation.d.ts +4 -0
  29. package/dist/runtime/harness_identity/extract.d.ts +1 -0
  30. package/dist/runtime/harness_identity/extract.js +7 -0
  31. package/dist/runtime/harness_identity/index.d.ts +1 -1
  32. package/dist/runtime/harness_identity/index.js +1 -1
  33. package/dist/runtime/prepare_routed_completion.js +8 -1
  34. package/dist/self_host/config.d.ts +1 -1
  35. package/dist/self_host/config.js +2 -1
  36. package/dist/self_host/main.js +38 -5
  37. package/dist/self_host/resolve.d.ts +1 -0
  38. package/dist/self_host/resolve.js +3 -3
  39. package/dist/strategies/selector_projection.js +3 -0
  40. package/dist/telemetry/report.d.ts +3 -1
  41. package/dist/telemetry/report.js +23 -0
  42. package/package.json +2 -2
  43. package/src/context/interaction_fingerprint.ts +15 -1
  44. package/src/context/interaction_fingerprint_format.ts +4 -0
  45. package/src/context/provider_prompt_accounting.ts +52 -6
  46. package/src/context/routing_context.ts +56 -2
  47. package/src/ingress/anthropic_messages.ts +15 -1
  48. package/src/ingress/codex.ts +12 -9
  49. package/src/interaction/capabilities.ts +7 -0
  50. package/src/interaction/document.ts +8 -0
  51. package/src/interaction/types.ts +8 -1
  52. package/src/observability/errors.ts +21 -0
  53. package/src/platform/platform.ts +22 -0
  54. package/src/protocol/input/anthropic_messages.ts +71 -1
  55. package/src/protocol/input/chat_completions.ts +6 -0
  56. package/src/protocol/input/chat_completions_validation.ts +8 -0
  57. package/src/protocol/input/openai_responses.ts +13 -0
  58. package/src/protocol/output/anthropic_messages_output.ts +7 -0
  59. package/src/providers/key_check.ts +76 -0
  60. package/src/providers/model_capabilities.ts +18 -0
  61. package/src/providers/pi_context.ts +14 -5
  62. package/src/runtime/harness_identity/conversation.ts +5 -0
  63. package/src/runtime/harness_identity/extract.ts +10 -0
  64. package/src/runtime/harness_identity/index.ts +1 -0
  65. package/src/runtime/prepare_routed_completion.ts +12 -1
  66. package/src/self_host/config.ts +5 -2
  67. package/src/self_host/main.ts +45 -5
  68. package/src/self_host/resolve.ts +3 -3
  69. package/src/strategies/selector_projection.ts +2 -0
  70. package/src/telemetry/report.ts +34 -1
package/README.md CHANGED
@@ -36,27 +36,26 @@ The gateway is the same runtime that serves Dari's hosted routing service. Choos
36
36
 
37
37
  ## Run
38
38
 
39
- Requires [Bun](https://bun.sh) 1.3 or later.
39
+ Requires [Bun](https://bun.sh) 1.3 or later. Write a `dari-gateway.yaml` (start from [`dari-gateway.example.yaml`](dari-gateway.example.yaml)), then:
40
40
 
41
41
  ```bash
42
- cp dari-gateway.example.yaml dari-gateway.yaml # edit routers and models
43
42
  export DARI_GATEWAY_KEYS="$(openssl rand -hex 24)" # keys your clients send
44
43
  export ANTHROPIC_API_KEY=... OPENAI_API_KEY=... # optional with subscriptions
45
- bun install
46
- bun run start
44
+ bunx @mupt-ai/dari-gateway
47
45
  ```
48
46
 
49
- Or with Docker, from this directory (in the monorepo, run `docker build -t dari-gateway dari-gateway` from the repository root):
47
+ Or with Docker:
50
48
 
51
49
  ```bash
52
- docker build -t dari-gateway .
53
50
  docker run -p 8080:8080 \
54
51
  -e DARI_GATEWAY_KEYS -e ANTHROPIC_API_KEY \
55
52
  -v "$PWD/dari-gateway.yaml:/config/dari-gateway.yaml" \
56
53
  -v dari-gateway-data:/data \
57
- dari-gateway
54
+ ghcr.io/mupt-ai/dari-gateway
58
55
  ```
59
56
 
57
+ From a clone, `bun install && bun run start` or `docker build -t dari-gateway .` run the same thing.
58
+
60
59
  | Variable | Meaning |
61
60
  | --- | --- |
62
61
  | `DARI_GATEWAY_KEYS` | Required. Comma-separated bearer keys clients authenticate with. |
@@ -65,7 +64,7 @@ docker run -p 8080:8080 \
65
64
  | `DARI_AUTO_ROUTING_API_KEY` | Optional. Select among a router's models with Dari Auto Routing. |
66
65
  | `PORT` | Listen port. Default `8080`. |
67
66
  | `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. |
68
- | Provider keys | Each provider's standard variable, such as `ANTHROPIC_API_KEY`, `OPENAI_API_KEY`, `META_API_KEY`, or `OPENROUTER_API_KEY`. |
67
+ | Provider keys | Each provider's standard variable, such as `ANTHROPIC_API_KEY`, `OPENAI_API_KEY`, `META_API_KEY`, or `OPENROUTER_API_KEY`. The gateway checks each key at startup and will not start if a provider rejects one. |
69
68
 
70
69
  ## Configure
71
70
 
@@ -74,7 +73,7 @@ routers:
74
73
  default: # serves /v1/...
75
74
  models:
76
75
  - anthropic/claude-sonnet-5-5
77
- - id: openai/gpt-5.5
76
+ - id: openai/gpt-6.1-sol
78
77
  thinking: [medium, high]
79
78
  subscriptions: true # prefer connected subscriptions over API keys
80
79
  sessionPinning: false # route each session and subagent once, then keep its model
@@ -121,9 +120,9 @@ The root entry needs no Bun APIs, so any runtime with web-standard `fetch`, `Req
121
120
  Platforms can also:
122
121
 
123
122
  - 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`);
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;
123
+ - 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-6.1-sol`; bare IDs and unknown models remain refused. This grant is ignored on non-strict routers and does not discover uncatalogued models;
125
124
  - 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;
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
+ - 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). Each record also carries the router's non-secret routing settings as `router` (enabled models, thinking levels, and the strict, speculative-routing, and session-pinning flags). Failed and aborted requests are recorded too;
127
126
  - attribute cost with `X-Dari-Tags: run=r1,trial=3`, which is parsed into `RequestRecord.tags` and the `recordUsage` payload's `tags`.
128
127
 
129
128
  ## Development
@@ -7,7 +7,7 @@ routers:
7
7
  default:
8
8
  models:
9
9
  - anthropic/claude-sonnet-5-5
10
- - id: openai/gpt-5.5
10
+ - id: openai/gpt-6.1-sol
11
11
  thinking: [medium, high]
12
12
  # Serve with connected Claude Code / ChatGPT subscriptions before API
13
13
  # keys (default true).
@@ -59,7 +59,7 @@ export function interactionLooseChain(routerId, request) {
59
59
  // transcript step. Growing history and tool changes preserve identity, while
60
60
  // unrelated work reusing the same harness session gets a new group.
61
61
  export function interactionConversationPrefix(request) {
62
- const firstStep = conversationFingerprintSteps(looseFingerprintSteps(request.sourceProtocol, request.items)).at(0);
62
+ const firstStep = conversationFingerprintSteps(looseFingerprintSteps(request.sourceProtocol, request.items.filter((item) => !isOpenClawRuntimeContext(item)))).at(0);
63
63
  return sha256(stableStringify({
64
64
  version: CONVERSATION_PREFIX_VERSION,
65
65
  firstStep: firstStep === undefined
@@ -67,6 +67,15 @@ export function interactionConversationPrefix(request) {
67
67
  : canonicalInteractionFingerprintStep(firstStep),
68
68
  }));
69
69
  }
70
+ // OpenClaw appends a user message of runtime context (active exec sessions,
71
+ // subagents) to each request and drops it from later history. On a first
72
+ // turn it merges into the first transcript step, so leaving it in would give
73
+ // the first turn a different conversation than the turns after it.
74
+ const OPENCLAW_RUNTIME_CONTEXT = "<<<BEGIN_OPENCLAW_INTERNAL_CONTEXT>>>";
75
+ function isOpenClawRuntimeContext(item) {
76
+ return item.type === "message" && item.role === "user" && item.content.length > 0 &&
77
+ item.content.every((part) => part.type === "text" && part.text.trimStart().startsWith(OPENCLAW_RUNTIME_CONTEXT));
78
+ }
70
79
  export function interactionPrefixContext(routerId, request, targets) {
71
80
  const chainByModel = new Map();
72
81
  const itemIndexByHashByModel = new Map();
@@ -6,6 +6,10 @@ export type FingerprintContent = string | Array<{
6
6
  type: "image";
7
7
  url: string;
8
8
  detail?: "auto" | "low" | "high" | "original";
9
+ } | {
10
+ type: "document";
11
+ mimeType: "application/pdf";
12
+ data: string;
9
13
  } | {
10
14
  type: "tool_reference";
11
15
  toolName: string;
@@ -83,6 +83,9 @@ export function interactionFingerprintContent(content, shape) {
83
83
  toolName: part.toolName,
84
84
  };
85
85
  }
86
+ if (part.type === "document") {
87
+ return { type: "document", mimeType: part.mimeType, data: part.data };
88
+ }
86
89
  return {
87
90
  type: "image",
88
91
  url: part.url,
@@ -1,3 +1,4 @@
1
+ import { constants as zlibConstants, inflateSync } from "node:zlib";
1
2
  import { isRecord, stableStringify, } from "@mupt-ai/dari-router/policy-engine";
2
3
  import { toolsByWireName } from "../interaction/tool_identity.js";
3
4
  import { buildContext } from "../providers/pi_context.js";
@@ -10,6 +11,45 @@ const IMAGE_ESTIMATED_TOKENS = {
10
11
  original: 2048,
11
12
  };
12
13
  const CHARS_PER_ESTIMATED_TOKEN = 4;
14
+ // Anthropic bills each PDF page as its text plus a page image: 1,500-3,000
15
+ // tokens. Use the top of that range so a long PDF cannot look like it fits.
16
+ const PDF_ESTIMATED_TOKENS_PER_PAGE = 3000;
17
+ const PDF_PAGE_PATTERN = /\/Type\s*\/Page(?![a-zA-Z])/g;
18
+ const PDF_OBJECT_STREAM_START = /\/Type\s*\/ObjStm[\s\S]*?stream\r?\n/g;
19
+ // Object streams hold small dictionaries. The cap is total inflated bytes per
20
+ // PDF, so a compression bomb in a request costs at most this much memory.
21
+ const PDF_OBJECT_STREAM_INFLATE_BUDGET = 4 * 1024 * 1024;
22
+ function estimatedPdfPages(base64) {
23
+ const bytes = Buffer.from(base64, "base64");
24
+ // latin1 maps each byte to one character, so string offsets are byte offsets.
25
+ const pdf = bytes.toString("latin1");
26
+ const visible = pdf.match(PDF_PAGE_PATTERN)?.length ?? 0;
27
+ if (visible > 0)
28
+ return visible;
29
+ // PDF 1.5+ may keep every page dictionary in compressed object streams.
30
+ let hidden = 0;
31
+ let budget = PDF_OBJECT_STREAM_INFLATE_BUDGET;
32
+ for (const match of pdf.matchAll(PDF_OBJECT_STREAM_START)) {
33
+ const start = match.index + match[0].length;
34
+ const end = pdf.indexOf("endstream", start);
35
+ if (end === -1 || budget <= 0)
36
+ break;
37
+ try {
38
+ const objects = inflateSync(bytes.subarray(start, end), {
39
+ finishFlush: zlibConstants.Z_SYNC_FLUSH,
40
+ maxOutputLength: budget,
41
+ });
42
+ budget -= objects.length;
43
+ hidden += objects.toString("latin1").match(PDF_PAGE_PATTERN)?.length ?? 0;
44
+ }
45
+ catch (error) {
46
+ // Over budget: stop. Another filter or bad data: count what is readable.
47
+ if (error instanceof RangeError)
48
+ break;
49
+ }
50
+ }
51
+ return Math.max(1, hidden);
52
+ }
13
53
  // Cost and cache-lookback estimates are candidate-specific because compatible
14
54
  // continuation state and provider serialization can change what each model
15
55
  // sees.
@@ -94,7 +134,7 @@ export function promptMetrics(request, target) {
94
134
  };
95
135
  return {
96
136
  blockDepth: messages.reduce((total, message) => total + providerVisibleBlockCount(message), 0),
97
- serializedChars: stableStringify(prompt).length + accountingInput.imageEstimatedChars,
137
+ serializedChars: stableStringify(prompt).length + accountingInput.attachmentEstimatedChars,
98
138
  };
99
139
  }
100
140
  function providerVisibleMessage(value) {
@@ -162,12 +202,20 @@ function providerVisibleTools(request, contextTools, target) {
162
202
  ];
163
203
  }
164
204
  function promptAccountingInput(request) {
165
- let imageEstimatedChars = 0;
205
+ let attachmentEstimatedChars = 0;
206
+ // Attachments are estimated rather than serialized: their base64 is not
207
+ // what the provider bills, and a PDF must not reach the serializer of a
208
+ // model that cannot carry it.
166
209
  const sanitizeContent = (parts) => parts.map((part) => {
210
+ if (part.type === "document") {
211
+ attachmentEstimatedChars +=
212
+ estimatedPdfPages(part.data) * PDF_ESTIMATED_TOKENS_PER_PAGE * CHARS_PER_ESTIMATED_TOKEN;
213
+ return { type: "text", text: "" };
214
+ }
167
215
  if (part.type !== "image")
168
216
  return part;
169
217
  const detail = part.detail ?? "auto";
170
- imageEstimatedChars +=
218
+ attachmentEstimatedChars +=
171
219
  IMAGE_ESTIMATED_TOKENS[detail] * CHARS_PER_ESTIMATED_TOKEN;
172
220
  return { ...part, url: ACCOUNTING_IMAGE_URL };
173
221
  });
@@ -182,6 +230,6 @@ function promptAccountingInput(request) {
182
230
  });
183
231
  return {
184
232
  request: { ...request, items },
185
- imageEstimatedChars,
233
+ attachmentEstimatedChars,
186
234
  };
187
235
  }
@@ -1,5 +1,6 @@
1
- import { type ActiveLease, type PendingLease, type CandidateCostEstimate, type ChainHits, type PrefixHit, type ChatMessage, type PreviousDecision, type PricingLookup, type SelectorLeaseHistoryEntry, type StrategyCandidateResolution } from "@mupt-ai/dari-router/policy-engine";
1
+ import { type ActiveLease, type PendingLease, type CandidateCostEstimate, type ChainHits, type PrefixHit, type ChatMessage, type PreviousDecision, type PricingLookup, type ReasoningEffort, type SelectorLeaseHistoryEntry, type StrategyCandidateResolution } from "@mupt-ai/dari-router/policy-engine";
2
2
  import { type InteractionRequest, type ProviderIdentity } from "../interaction/types.js";
3
+ import { modelMetadataLookup } from "../providers/pi.js";
3
4
  import { type SelectorImage } from "../strategies/selector_projection.js";
4
5
  import type { ResolvedRouter } from "../types.js";
5
6
  import { type InteractionPrefixContext } from "./interaction_fingerprint.js";
@@ -38,3 +39,7 @@ export declare function buildRoutingContext(args: {
38
39
  explicitConversationId?: string;
39
40
  receivedAt?: Date;
40
41
  }): Promise<RoutingContext>;
42
+ export declare function clampRequestedEffort(interaction: InteractionRequest, credentials: {
43
+ strict?: boolean;
44
+ model_thinking_levels?: Record<string, ReasoningEffort[]>;
45
+ }, metadataLookup: ReturnType<typeof modelMetadataLookup>): InteractionRequest;
@@ -1,14 +1,15 @@
1
- import { prepareCandidates, } from "@mupt-ai/dari-router/policy-engine";
1
+ import { prepareCandidates, isReasoningEffort, } from "@mupt-ai/dari-router/policy-engine";
2
2
  import { randomUUID } from "node:crypto";
3
3
  import { HttpError } from "../http/errors.js";
4
4
  import { effectiveContextWindow } from "../models/context_limits.js";
5
5
  import { keySourceForModel } from "../models/execution_provider.js";
6
- import { requiredCapabilitiesFor } from "../interaction/capabilities.js";
6
+ import { requestHasDocument, requiredCapabilitiesFor } from "../interaction/capabilities.js";
7
7
  import { providerIdentityFromModel } from "../interaction/provider_continuation_state.js";
8
8
  import { isBackgroundRequest } from "../interaction/types.js";
9
9
  import { requireCandidateModels } from "../models/ids.js";
10
+ import { explicitModelId } from "../protocol/input/router_model.js";
10
11
  import { modelMetadataLookup } from "../providers/pi.js";
11
- import { modelSupportsImageInput, modelSupportsHostedWebSearch, modelSupportsReasoningConfigurationUpdate, providerApiCanSerializeResponseFormat, } from "../providers/model_capabilities.js";
12
+ import { modelSupportsDocumentInput, modelSupportsImageInput, modelSupportsHostedWebSearch, modelSupportsReasoningConfigurationUpdate, providerApiCanSerializeResponseFormat, } from "../providers/model_capabilities.js";
12
13
  import { selectorMessagesForInteraction, } from "../strategies/selector_projection.js";
13
14
  import { interactionPrefixContext, interactionPrefixLookupHashes, } from "./interaction_fingerprint.js";
14
15
  import { pricingLookupForRouter } from "./pricing.js";
@@ -64,6 +65,7 @@ export async function buildRoutingContext(args) {
64
65
  enabled: args.credentials.image_capability_filtering !== false,
65
66
  supportsImageInput: (model) => capabilityLookup(model).supportsImageInput,
66
67
  });
68
+ const documentCapableModels = documentCapableCandidates(imageFilter.candidateModels, requestHasDocument(args.interaction), (model) => modelSupportsDocumentInput(model, metadataLookup(model)));
67
69
  const prefix = derivePrefixContext({
68
70
  routerId: args.routerId,
69
71
  interaction: args.interaction,
@@ -81,7 +83,7 @@ export async function buildRoutingContext(args) {
81
83
  const prefixHits = (background ? [] : args.credentials.prefix_hits ?? []).filter((hit) => args.explicitConversationId === undefined
82
84
  || hit.conversation_id === args.explicitConversationId);
83
85
  const prepared = prepareCandidates({
84
- candidateModels: imageFilter.candidateModels,
86
+ candidateModels: documentCapableModels,
85
87
  metadataLookup: capabilityLookup,
86
88
  requiredCapabilities: imageFilter.requiredCapabilities,
87
89
  requestedReasoningEffort: args.interaction.reasoning?.effort,
@@ -195,6 +197,17 @@ function resolveImageCapableCandidates(args) {
195
197
  .map((model) => ({ model, reason: "image_input_unsupported" })),
196
198
  };
197
199
  }
200
+ // A PDF can only be sent natively, so a conversation carrying one must not
201
+ // route (or hold a lease) to a model that cannot read it.
202
+ function documentCapableCandidates(candidateModels, hasDocument, supportsDocumentInput) {
203
+ if (!hasDocument)
204
+ return candidateModels;
205
+ const capable = candidateModels.filter(supportsDocumentInput);
206
+ if (capable.length === 0) {
207
+ throw new HttpError(400, "This conversation contains a PDF document, but no enabled model on this router accepts PDF input.", "no_document_capable_candidates");
208
+ }
209
+ return capable;
210
+ }
198
211
  function derivePrefixContext(args) {
199
212
  try {
200
213
  return interactionPrefixContext(args.routerId, args.interaction, args.targetsByModel);
@@ -209,3 +222,26 @@ function derivePrefixContext(args) {
209
222
  };
210
223
  }
211
224
  }
225
+ // An explicit `dari/model/<id>` serves the requested thinking level when the
226
+ // model offers it and otherwise its default level, so the request itself drops
227
+ // an unsupported level before routing and provider validation see it. A strict
228
+ // router never substitutes, so it keeps the level and fails on it.
229
+ export function clampRequestedEffort(interaction, credentials, metadataLookup) {
230
+ const requested = interaction.reasoning?.effort;
231
+ const modelId = explicitModelId(interaction.model);
232
+ if (requested === undefined || modelId === null || credentials.strict === true)
233
+ return interaction;
234
+ const supported = metadataLookup(modelId).supportedThinkingLevels.filter(isReasoningEffort);
235
+ const configured = credentials.model_thinking_levels?.[modelId] ?? supported;
236
+ if (configured.includes(requested) && supported.includes(requested))
237
+ return interaction;
238
+ // The effort implied thinking on and its effort-derived fields (baseEffort,
239
+ // configuration updates) must not leak into the default. Keep only an explicit
240
+ // thinking-off and any thinking budget.
241
+ const { enabled, budgetTokens } = interaction.reasoning;
242
+ const reasoning = {
243
+ ...(enabled === false ? { enabled } : {}),
244
+ ...(budgetTokens === undefined ? {} : { budgetTokens }),
245
+ };
246
+ return { ...interaction, reasoning: Object.keys(reasoning).length === 0 ? undefined : reasoning };
247
+ }
@@ -5,6 +5,7 @@ import { anthropicToken } from "../http/auth.js";
5
5
  import { errorResponseHeaders, normalizeError } from "../http/errors.js";
6
6
  import { captureRequestError } from "../observability/errors.js";
7
7
  import { anthropicMessagesToInteraction } from "../protocol/input/anthropic_messages.js";
8
+ import { explicitModelId } from "../protocol/input/router_model.js";
8
9
  import { handleRoutedCompletion } from "../runtime/routed_completion.js";
9
10
  import { claudeCodeRequestContext } from "./claude_code/request_context.js";
10
11
  import { claudeCodeConversationIdentity, } from "../runtime/harness_identity/index.js";
@@ -24,6 +25,7 @@ export async function handleAnthropicMessages(request, routerId, selectorBackend
24
25
  const payload = isClaudeCode
25
26
  ? {
26
27
  ...forwarded,
28
+ reasoning: routedReasoning(forwarded),
27
29
  agentContext: claudeCodeRequestContext(request.headers),
28
30
  // Native tool results may be followed by harness-injected text.
29
31
  // Preserve their structural continuation semantics, not prompt inference.
@@ -58,6 +60,16 @@ export async function handleAnthropicMessages(request, routerId, selectorBackend
58
60
  return response;
59
61
  }
60
62
  }
63
+ // Claude Code sends its session effort on every turn. That preference
64
+ // describes a direct model, not a heterogeneous router, so let Dari select the
65
+ // effort when the router routes; a `dari/model/<id>` keeps the chosen effort.
66
+ function routedReasoning(interaction) {
67
+ const reasoning = interaction.reasoning;
68
+ if (reasoning === undefined || explicitModelId(interaction.model) !== null)
69
+ return reasoning;
70
+ // Keep only an explicit "thinking off"; effort alone must not force thinking on.
71
+ return reasoning.enabled === false ? { enabled: false } : undefined;
72
+ }
61
73
  function latestMessageHasToolResult(messages) {
62
74
  if (!Array.isArray(messages))
63
75
  return false;
@@ -8,7 +8,7 @@ import { openAIResponsesToInteraction } from "../protocol/input/openai_responses
8
8
  import { parseJsonRequest } from "./json.js";
9
9
  import { museCodeClientId } from "./muse_code.js";
10
10
  import { handleRoutedCompletion } from "../runtime/routed_completion.js";
11
- import { codexConversationIdentity, codexRequestPurpose, codexRequestContext, } from "../runtime/harness_identity/index.js";
11
+ import { codexConversationIdentity, codexRequestPurpose, codexRequestContext, museCodeConversationIdentity, } from "../runtime/harness_identity/index.js";
12
12
  export async function handleResponses(request, routerId, selectorBackend) {
13
13
  try {
14
14
  const apiKey = bearerToken(request.headers);
@@ -23,14 +23,19 @@ export async function handleResponses(request, routerId, selectorBackend) {
23
23
  ...(agentContext === undefined ? {} : { agentContext }),
24
24
  ...(museClientId === undefined ? {} : { museCodeClientId: museClientId }),
25
25
  };
26
- const harnessConversationIdentity = codexConversationIdentity(request.headers, rawPayload);
27
- // Codex applies its global model_reasoning_effort setting to custom model
26
+ const codexIdentity = codexConversationIdentity(request.headers, rawPayload);
27
+ // Codex and Muse Code apply their session effort to custom model
28
28
  // providers. That preference describes a direct model, not a heterogeneous
29
- // router, so let Dari select the reasoning effort for verified Codex turns.
30
- // Parsing above still validates malformed Responses reasoning controls.
31
- if (harnessConversationIdentity !== undefined && explicitModelId(payload.model) === null) {
32
- payload.reasoning = undefined;
29
+ // router, so let Dari select the reasoning effort for their routed turns;
30
+ // a `dari/model/<id>` keeps the chosen effort. Parsing above still
31
+ // validates malformed Responses reasoning controls.
32
+ const harness = codexIdentity !== undefined || museClientId !== undefined;
33
+ if (harness && explicitModelId(payload.model) === null) {
34
+ // Keep only an explicit thinking-off; a session effort must not force thinking on.
35
+ payload.reasoning = payload.reasoning?.enabled === false ? { enabled: false } : undefined;
33
36
  }
37
+ const harnessConversationIdentity = codexIdentity
38
+ ?? (museClientId === undefined ? undefined : museCodeConversationIdentity(request.headers));
34
39
  return await handleRoutedCompletion({
35
40
  request,
36
41
  routerId,
@@ -1,3 +1,4 @@
1
1
  import type { HardCapability } from "@mupt-ai/dari-router/policy-engine";
2
2
  import type { InteractionRequest } from "./types.js";
3
3
  export declare function requiredCapabilitiesFor(request: Pick<InteractionRequest, "items" | "responseFormat" | "toolChoice" | "tools">): HardCapability[];
4
+ export declare function requestHasDocument(request: Pick<InteractionRequest, "items">): boolean;
@@ -17,6 +17,12 @@ export function requiredCapabilitiesFor(request) {
17
17
  function requiresWebSearch(item) {
18
18
  return item.type === "hosted_tool_call" && item.tool === "web_search";
19
19
  }
20
+ export function requestHasDocument(request) {
21
+ return request.items.some((item) => {
22
+ const parts = item.type === "message" ? item.content : item.type === "tool_result" ? item.output : null;
23
+ return Array.isArray(parts) && parts.some((part) => part.type === "document");
24
+ });
25
+ }
20
26
  function itemHasImage(item) {
21
27
  if (item.type === "message")
22
28
  return item.content.some((part) => part.type === "image");
@@ -0,0 +1,2 @@
1
+ /** The base64 payload of a PDF data URL, or undefined for anything else. */
2
+ export declare function parsePdfDataUrl(url: string): string | undefined;
@@ -0,0 +1,8 @@
1
+ const PDF_DATA_URL_PREFIX = "data:application/pdf;base64,";
2
+ /** The base64 payload of a PDF data URL, or undefined for anything else. */
3
+ export function parsePdfDataUrl(url) {
4
+ if (!url.startsWith(PDF_DATA_URL_PREFIX))
5
+ return undefined;
6
+ const data = url.slice(PDF_DATA_URL_PREFIX.length);
7
+ return data.length > 0 ? data : undefined;
8
+ }
@@ -32,11 +32,17 @@ export type InteractionImage = {
32
32
  url: string;
33
33
  detail?: "auto" | "low" | "high" | "original";
34
34
  };
35
+ /** A base64-encoded PDF. Only Anthropic Messages targets accept documents. */
36
+ export type InteractionDocument = {
37
+ type: "document";
38
+ mimeType: "application/pdf";
39
+ data: string;
40
+ };
35
41
  export type InteractionToolReference = {
36
42
  type: "tool_reference";
37
43
  toolName: string;
38
44
  };
39
- export type InteractionContent = InteractionText | InteractionImage | InteractionToolReference;
45
+ export type InteractionContent = InteractionText | InteractionImage | InteractionDocument | InteractionToolReference;
40
46
  export type InteractionMessage = {
41
47
  type: "message";
42
48
  id?: string;
@@ -1,7 +1,26 @@
1
+ import { HttpError } from "../http/errors.js";
1
2
  import { gatewayPlatform, } from "../platform/platform.js";
2
3
  export function captureRequestError(error, context) {
4
+ logUnsupportedInput(error, context);
3
5
  gatewayPlatform().captureRequestError(error, context);
4
6
  }
7
+ /* Platforms report only 5xx, so a client sending a block the gateway cannot
8
+ carry would otherwise be invisible. Log the code and parameter path, never
9
+ request content. */
10
+ function logUnsupportedInput(error, context) {
11
+ if (!(error instanceof HttpError) || error.status >= 500)
12
+ return;
13
+ if (!error.code.startsWith("unsupported_") && !error.code.startsWith("no_"))
14
+ return;
15
+ console.warn("routing request unsupported input", JSON.stringify({
16
+ router_id: context.routerId,
17
+ request_id: context.requestId ?? null,
18
+ source_protocol: context.sourceProtocol,
19
+ error_code: error.code,
20
+ param: error.param ?? null,
21
+ message: error.message,
22
+ }));
23
+ }
5
24
  /**
6
25
  * Report failures deliberately caught outside the ingress exception path,
7
26
  * including work after a response has started and process shutdown.
@@ -110,6 +110,20 @@ export type RequestRecord = {
110
110
  zdr: boolean;
111
111
  harnessConversationIdentity?: HarnessConversationIdentity;
112
112
  tags: RequestTags;
113
+ router: RequestRecordRouter;
114
+ candidates: Array<{
115
+ model: string;
116
+ reasoningEffort: ReasoningEffort;
117
+ }>;
118
+ sessionPinKey?: string;
119
+ };
120
+ export type RequestRecordRouter = {
121
+ enabledModels: string[];
122
+ thinkingLevels: Record<string, ReasoningEffort[]>;
123
+ defaultThinkingLevels: Record<string, ReasoningEffort>;
124
+ strict: boolean;
125
+ speculativeRouting: boolean;
126
+ sessionPinning: boolean;
113
127
  };
114
128
  export type RequestTags = Record<string, string>;
115
129
  export type RequestSummary = {
@@ -102,6 +102,18 @@ function appendAnthropicMessage(items, role, value, messageIndex) {
102
102
  buffered.push(anthropicImage(rawBlock.source, `${param}.source`));
103
103
  continue;
104
104
  }
105
+ if (type === "document") {
106
+ if (role !== "user")
107
+ throw invalidRequest("Anthropic document blocks are only supported on user messages.", param);
108
+ buffered.push(...anthropicDocument(rawBlock, param));
109
+ continue;
110
+ }
111
+ if (type === "search_result") {
112
+ if (role !== "user")
113
+ throw invalidRequest("Anthropic search_result blocks are only supported on user messages.", param);
114
+ buffered.push(anthropicSearchResult(rawBlock, param));
115
+ continue;
116
+ }
105
117
  if (type === "thinking" || type === "redacted_thinking") {
106
118
  if (role !== "assistant")
107
119
  throw invalidRequest(`${type} blocks require the assistant role.`, param);
@@ -212,6 +224,63 @@ function appendAnthropicMessage(items, role, value, messageIndex) {
212
224
  }
213
225
  flushMessage();
214
226
  }
227
+ /* PDFs stay native documents; they route only to models that read PDFs.
228
+ Text and content documents become ordinary parts so every model can read
229
+ them. Title and context travel as a leading text part. Citations are not
230
+ requested from the provider, so the citations setting is dropped. */
231
+ function anthropicDocument(block, param) {
232
+ const source = block.source;
233
+ const sourceParam = `${param}.source`;
234
+ if (!isRecord(source))
235
+ throw invalidRequest(`${sourceParam} must be an object.`, sourceParam);
236
+ const header = [optionalString(block.title, `${param}.title`), optionalString(block.context, `${param}.context`)]
237
+ .filter((line) => line !== undefined && line.length > 0)
238
+ .join("\n");
239
+ const withHeader = (body) => header ? [{ type: "text", text: `Document: ${header}` }, ...body] : body;
240
+ const type = requiredString(source.type, `${sourceParam}.type`);
241
+ if (type === "base64") {
242
+ const mediaType = requiredString(source.media_type, `${sourceParam}.media_type`);
243
+ if (mediaType !== "application/pdf") {
244
+ throw new HttpError(400, "Base64 document sources must be application/pdf.", "unsupported_document_source", `${sourceParam}.media_type`);
245
+ }
246
+ const document = { type: "document", mimeType: "application/pdf", data: requiredString(source.data, `${sourceParam}.data`) };
247
+ return withHeader([document]);
248
+ }
249
+ if (type === "text") {
250
+ return withHeader([{ type: "text", text: requiredStringAllowEmpty(source.data, `${sourceParam}.data`) }]);
251
+ }
252
+ if (type === "content") {
253
+ if (typeof source.content === "string")
254
+ return withHeader([{ type: "text", text: source.content }]);
255
+ if (!Array.isArray(source.content))
256
+ throw invalidRequest(`${sourceParam}.content must be a string or an array.`, `${sourceParam}.content`);
257
+ return withHeader(source.content.map((part, index) => {
258
+ const partParam = `${sourceParam}.content.${index}`;
259
+ if (!isRecord(part))
260
+ throw invalidRequest(`${partParam} must be an object.`, partParam);
261
+ if (part.type === "text")
262
+ return { type: "text", text: requiredStringAllowEmpty(part.text, `${partParam}.text`) };
263
+ if (part.type === "image")
264
+ return anthropicImage(part.source, `${partParam}.source`);
265
+ throw new HttpError(400, `Document content block type '${String(part.type ?? "unknown")}' is not supported.`, "unsupported_content_block", partParam);
266
+ }));
267
+ }
268
+ throw new HttpError(400, `Anthropic document source type '${type}' is not supported; use a base64 PDF, text, or content source.`, "unsupported_document_source", `${sourceParam}.type`);
269
+ }
270
+ // Search results are text with provenance; they reach every model as text.
271
+ function anthropicSearchResult(block, param) {
272
+ const title = requiredStringAllowEmpty(block.title, `${param}.title`);
273
+ const source = requiredStringAllowEmpty(block.source, `${param}.source`);
274
+ if (!Array.isArray(block.content))
275
+ throw invalidRequest(`${param}.content must be an array.`, `${param}.content`);
276
+ const body = block.content.map((part, index) => {
277
+ const partParam = `${param}.content.${index}`;
278
+ if (!isRecord(part) || part.type !== "text")
279
+ throw invalidRequest(`${partParam} must be a text block.`, partParam);
280
+ return requiredStringAllowEmpty(part.text, `${partParam}.text`);
281
+ });
282
+ return { type: "text", text: [`Search result: ${title}`, `Source: ${source}`, "", ...body].join("\n") };
283
+ }
215
284
  function anthropicImage(value, param) {
216
285
  if (!isRecord(value))
217
286
  throw invalidRequest(`${param} must be an object.`, param);
@@ -264,7 +333,7 @@ function anthropicToolResultContent(value, param) {
264
333
  if (!Array.isArray(value))
265
334
  throw invalidRequest(`${param} must be a string or an array.`, param);
266
335
  return {
267
- output: value.map((rawBlock, index) => {
336
+ output: value.flatMap((rawBlock, index) => {
268
337
  const blockParam = `${param}.${index}`;
269
338
  if (!isRecord(rawBlock))
270
339
  throw invalidRequest(`${blockParam} must be an object.`, blockParam);
@@ -273,6 +342,10 @@ function anthropicToolResultContent(value, param) {
273
342
  }
274
343
  if (rawBlock.type === "image")
275
344
  return anthropicImage(rawBlock.source, `${blockParam}.source`);
345
+ if (rawBlock.type === "document")
346
+ return anthropicDocument(rawBlock, blockParam);
347
+ if (rawBlock.type === "search_result")
348
+ return [anthropicSearchResult(rawBlock, blockParam)];
276
349
  if (rawBlock.type === "tool_reference") {
277
350
  return {
278
351
  type: "tool_reference",
@@ -3,6 +3,7 @@ import { HttpError } from "../../http/errors.js";
3
3
  import { toolsByWireName } from "../../interaction/tool_identity.js";
4
4
  import { decodeUntrustedProviderContinuationState } from "../../interaction/provider_continuation_state.js";
5
5
  import { validateChatCompletionsRequest } from "./chat_completions_validation.js";
6
+ import { parsePdfDataUrl } from "../../interaction/document.js";
6
7
  export function chatCompletionsToInteraction(request) {
7
8
  validateChatCompletionsRequest(request);
8
9
  return internalChatCompletionsToInteraction(request);
@@ -177,6 +178,11 @@ function messageContent(content) {
177
178
  if (part.type === "tool_reference") {
178
179
  return { type: "text", text: `Referenced tool: ${String(part.tool_name)}` };
179
180
  }
181
+ if (part.type === "file") {
182
+ // Validation guarantees a base64 PDF data URL.
183
+ const fileData = String(part.file.file_data);
184
+ return { type: "document", mimeType: "application/pdf", data: parsePdfDataUrl(fileData) };
185
+ }
180
186
  const imageUrl = part.image_url;
181
187
  const detail = imageUrl.detail;
182
188
  return {
@@ -3,6 +3,7 @@ import { HttpError } from "../../http/errors.js";
3
3
  import { parseDataImageUrl } from "../../interaction/image.js";
4
4
  import { CANONICAL_ROUTER_MODEL, explicitModelId, unsupportedModelMessage } from "./router_model.js";
5
5
  import { isProtocolToolCallId, TOOL_CALL_ID_MAX_LENGTH, } from "../../interaction/tool_identity.js";
6
+ import { parsePdfDataUrl } from "../../interaction/document.js";
6
7
  export function validateChatCompletionsRequest(request) {
7
8
  if (request.model !== CANONICAL_ROUTER_MODEL && explicitModelId(request.model) === null) {
8
9
  throw new HttpError(400, unsupportedModelMessage(), "unsupported_model", "model");
@@ -103,6 +104,13 @@ function validateMessageContent(content, messageIndex, role) {
103
104
  }
104
105
  return;
105
106
  }
107
+ if (part.type === "file") {
108
+ const fileData = isRecord(part.file) ? part.file.file_data : undefined;
109
+ if (typeof fileData !== "string" || parsePdfDataUrl(fileData) === undefined) {
110
+ throw new HttpError(400, `Unsupported file at messages[${messageIndex}].content[${partIndex}]; use a base64 PDF data URL in file.file_data.`, "unsupported_file_source", `messages.${messageIndex}.content.${partIndex}.file`);
111
+ }
112
+ return;
113
+ }
106
114
  if (part.type === "image_url") {
107
115
  const imageUrl = part.image_url;
108
116
  const url = isRecord(imageUrl) && typeof imageUrl.url === "string" ? imageUrl.url : "";