@mupt-ai/dari-gateway 0.3.3 → 0.3.5

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 (68) hide show
  1. package/README.md +7 -5
  2. package/dari-gateway.example.yaml +1 -1
  3. package/dist/context/interaction_fingerprint_format.d.ts +4 -0
  4. package/dist/context/interaction_fingerprint_format.js +3 -0
  5. package/dist/context/provider_prompt_accounting.js +52 -4
  6. package/dist/context/routing_context.d.ts +6 -1
  7. package/dist/context/routing_context.js +40 -4
  8. package/dist/ingress/anthropic_messages.js +12 -0
  9. package/dist/ingress/codex.js +8 -5
  10. package/dist/interaction/capabilities.d.ts +1 -0
  11. package/dist/interaction/capabilities.js +6 -0
  12. package/dist/interaction/document.d.ts +2 -0
  13. package/dist/interaction/document.js +8 -0
  14. package/dist/interaction/types.d.ts +7 -1
  15. package/dist/models/canonical.d.ts +2 -0
  16. package/dist/models/canonical.js +3 -0
  17. package/dist/models/canonical_model_ids.json +33 -0
  18. package/dist/models/runtime_overrides.js +14 -0
  19. package/dist/observability/errors.js +19 -0
  20. package/dist/platform/platform.d.ts +14 -0
  21. package/dist/protocol/input/anthropic_messages.js +74 -1
  22. package/dist/protocol/input/chat_completions.js +6 -0
  23. package/dist/protocol/input/chat_completions_validation.js +8 -0
  24. package/dist/protocol/input/openai_responses.js +8 -0
  25. package/dist/protocol/output/anthropic_messages_output.js +7 -0
  26. package/dist/providers/key_check.d.ts +31 -0
  27. package/dist/providers/key_check.js +54 -0
  28. package/dist/providers/model_capabilities.d.ts +2 -0
  29. package/dist/providers/model_capabilities.js +15 -0
  30. package/dist/providers/pi_context.js +13 -5
  31. package/dist/runtime/prepare_routed_completion.js +8 -1
  32. package/dist/self_host/config.d.ts +8 -2
  33. package/dist/self_host/config.js +30 -1
  34. package/dist/self_host/main.d.ts +4 -1
  35. package/dist/self_host/main.js +62 -6
  36. package/dist/self_host/resolve.d.ts +1 -0
  37. package/dist/self_host/resolve.js +3 -3
  38. package/dist/strategies/selector_projection.js +3 -0
  39. package/dist/telemetry/report.d.ts +3 -1
  40. package/dist/telemetry/report.js +23 -0
  41. package/package.json +3 -2
  42. package/src/context/interaction_fingerprint_format.ts +4 -0
  43. package/src/context/provider_prompt_accounting.ts +52 -6
  44. package/src/context/routing_context.ts +56 -2
  45. package/src/ingress/anthropic_messages.ts +15 -1
  46. package/src/ingress/codex.ts +8 -5
  47. package/src/interaction/capabilities.ts +7 -0
  48. package/src/interaction/document.ts +8 -0
  49. package/src/interaction/types.ts +8 -1
  50. package/src/models/canonical.ts +4 -0
  51. package/src/models/canonical_model_ids.json +33 -0
  52. package/src/models/runtime_overrides.ts +14 -0
  53. package/src/observability/errors.ts +21 -0
  54. package/src/platform/platform.ts +22 -0
  55. package/src/protocol/input/anthropic_messages.ts +71 -1
  56. package/src/protocol/input/chat_completions.ts +6 -0
  57. package/src/protocol/input/chat_completions_validation.ts +8 -0
  58. package/src/protocol/input/openai_responses.ts +13 -0
  59. package/src/protocol/output/anthropic_messages_output.ts +7 -0
  60. package/src/providers/key_check.ts +76 -0
  61. package/src/providers/model_capabilities.ts +18 -0
  62. package/src/providers/pi_context.ts +14 -5
  63. package/src/runtime/prepare_routed_completion.ts +12 -1
  64. package/src/self_host/config.ts +33 -3
  65. package/src/self_host/main.ts +68 -6
  66. package/src/self_host/resolve.ts +3 -3
  67. package/src/strategies/selector_projection.ts +2 -0
  68. package/src/telemetry/report.ts +34 -1
@@ -0,0 +1,76 @@
1
+ import { getBuiltinModels } from "@mupt-ai/pi-ai/providers/all";
2
+
3
+ const CHECK_TIMEOUT_MS = 10_000;
4
+
5
+ /**
6
+ * - `valid`: the provider accepted the key.
7
+ * - `rejected`: the provider refused it (401 or 403).
8
+ * - `unchecked`: no answer either way, such as a network error, an outage, or
9
+ * a provider without a known check. Callers treat this as not rejected so a
10
+ * provider outage never blocks a key.
11
+ */
12
+ export type ProviderKeyCheckResult =
13
+ | { result: "valid" }
14
+ | { result: "rejected"; status: number }
15
+ | { result: "unchecked" };
16
+
17
+ // A provider endpoint outside the model catalog, such as a self-run server.
18
+ export type ProviderKeyEndpoint = { api: string; baseUrl: string };
19
+
20
+ /**
21
+ * Ask a provider whether an API key works, with the cheapest authenticated
22
+ * request its wire API offers: list models, or, where listing models needs
23
+ * no key, an endpoint that does. The self-hosted gateway runs this at startup
24
+ * and the hosted control plane before it saves a key.
25
+ */
26
+ export async function checkProviderKey(args: {
27
+ provider: string;
28
+ key: string;
29
+ endpoint?: ProviderKeyEndpoint;
30
+ fetch: typeof fetch;
31
+ }): Promise<ProviderKeyCheckResult> {
32
+ const check = keyCheckRequest(args.provider, args.key, args.endpoint);
33
+ if (check === null) return { result: "unchecked" };
34
+ try {
35
+ const response = await args.fetch(check.url, {
36
+ headers: check.headers,
37
+ signal: AbortSignal.timeout(CHECK_TIMEOUT_MS),
38
+ });
39
+ await response.body?.cancel();
40
+ if (response.status === 401 || response.status === 403) {
41
+ return { result: "rejected", status: response.status };
42
+ }
43
+ return response.ok ? { result: "valid" } : { result: "unchecked" };
44
+ } catch {
45
+ return { result: "unchecked" };
46
+ }
47
+ }
48
+
49
+ function keyCheckRequest(
50
+ provider: string,
51
+ key: string,
52
+ endpoint: ProviderKeyEndpoint | undefined,
53
+ ): { url: string; headers: Record<string, string> } | null {
54
+ const bearer = { authorization: `Bearer ${key}` };
55
+ if (endpoint === undefined) {
56
+ if (provider === "openrouter") return { url: "https://openrouter.ai/api/v1/key", headers: bearer };
57
+ if (provider === "vercel-ai-gateway") return { url: "https://ai-gateway.vercel.sh/v1/credits", headers: bearer };
58
+ }
59
+ const target = endpoint
60
+ ?? (getBuiltinModels(provider as never) as unknown as ProviderKeyEndpoint[] | undefined)?.[0];
61
+ if (target === undefined || !URL.canParse(target.baseUrl)) return null;
62
+ const base = target.baseUrl.replace(/\/+$/, "");
63
+ switch (target.api) {
64
+ case "anthropic-messages":
65
+ return { url: `${base}/v1/models`, headers: { "x-api-key": key, "anthropic-version": "2023-06-01" } };
66
+ case "google-generative-ai":
67
+ return { url: `${base}/models`, headers: { "x-goog-api-key": key } };
68
+ case "mistral-conversations":
69
+ return { url: `${base}/v1/models`, headers: bearer };
70
+ case "openai-completions":
71
+ case "openai-responses":
72
+ return { url: `${base}/models`, headers: bearer };
73
+ default:
74
+ return null;
75
+ }
76
+ }
@@ -14,6 +14,24 @@ export function modelSupportsImageInput(metadata: ModelCapabilityMetadata): bool
14
14
  return Array.isArray(metadata.model.input) && metadata.model.input.includes("image");
15
15
  }
16
16
 
17
+ /* PDFs are sent natively only where the model owner documents PDF input on
18
+ that API. The same API can serve other owners (xAI and Meta on Responses,
19
+ GPT on Bedrock) whose PDF support is unverified, so both must match. */
20
+ const DOCUMENT_INPUT_APIS_BY_OWNER: Readonly<Record<string, ReadonlySet<string>>> = {
21
+ anthropic: new Set(["anthropic-messages", "bedrock-converse-stream"]),
22
+ openai: new Set(["openai-responses", "azure-openai-responses", "openai-codex-responses"]),
23
+ };
24
+
25
+ export function apiSupportsDocumentInput(api: string): boolean {
26
+ return Object.values(DOCUMENT_INPUT_APIS_BY_OWNER).some((apis) => apis.has(api));
27
+ }
28
+
29
+ export function modelSupportsDocumentInput(modelId: string, metadata: ModelCapabilityMetadata): boolean {
30
+ const owner = modelId.split("/", 1)[0] ?? "";
31
+ const api = metadata.model.api;
32
+ return typeof api === "string" && DOCUMENT_INPUT_APIS_BY_OWNER[owner]?.has(api) === true;
33
+ }
34
+
17
35
  export function providerApiCanSerializeResponseFormat(metadata: ModelCapabilityMetadata): boolean {
18
36
  return (
19
37
  metadata.model.api === "openai-responses" ||
@@ -17,6 +17,7 @@ import {
17
17
  sameProviderIdentity,
18
18
  } from "../interaction/provider_continuation_state.js";
19
19
  import { isOpenAIResponsesApi } from "./model_capabilities.js";
20
+ import { apiSupportsDocumentInput } from "./model_capabilities.js";
20
21
 
21
22
  const UNKNOWN_SOURCE: ProviderIdentity = {
22
23
  provider: "dari",
@@ -104,7 +105,7 @@ export function buildContext(
104
105
  sawConversationItem = true;
105
106
  if (item.type === "message" && item.role === "user") {
106
107
  flushAssistant();
107
- appendUserMessage(messages, userContent(item.content, item.contentShape), timestamp);
108
+ appendUserMessage(messages, userContent(item.content, item.contentShape, target), timestamp);
108
109
  continue;
109
110
  }
110
111
  if (item.type === "tool_result") {
@@ -297,10 +298,11 @@ function thinkingContent(
297
298
 
298
299
  function userContent(
299
300
  content: InteractionContent[],
300
- shape: "string" | "parts" | "null" | "absent"
301
+ shape: "string" | "parts" | "null" | "absent",
302
+ target: ProviderIdentity,
301
303
  ): string | Array<Record<string, unknown>> {
302
304
  if (shape !== "parts") return content.filter((part) => part.type === "text").map((part) => part.text).join("");
303
- return content.map(contentToPi);
305
+ return content.map((part) => contentToPi(part, target));
304
306
  }
305
307
 
306
308
  function appendPositionalNote(
@@ -347,12 +349,19 @@ function toolResultContent(
347
349
  const filtered = target.api === "anthropic-messages"
348
350
  ? content.filter((part) => part.type !== "tool_reference")
349
351
  : content;
350
- return filtered.map(contentToPi);
352
+ return filtered.map((part) => contentToPi(part, target));
351
353
  }
352
354
 
353
- function contentToPi(content: InteractionContent): Record<string, unknown> {
355
+ function contentToPi(content: InteractionContent, target: ProviderIdentity): Record<string, unknown> {
354
356
  if (content.type === "text") return { type: "text", text: content.text };
355
357
  if (content.type === "tool_reference") return { type: "text", text: `Referenced tool: ${content.toolName}` };
358
+ if (content.type === "document") {
359
+ // Routing keeps PDFs on document-capable models; this guards direct callers.
360
+ if (!apiSupportsDocumentInput(target.api)) {
361
+ throw new HttpError(400, `PDF document input is not supported by the ${target.api} API.`, "unsupported_input_modality", "input.document");
362
+ }
363
+ return { type: "document", mimeType: content.mimeType, data: content.data };
364
+ }
356
365
  const image = parseDataImageUrl(content.url);
357
366
  if (!image) {
358
367
  throw new HttpError(400, "Only base64 data URLs are supported for routed image input.", "invalid_request_error", "input.image_url");
@@ -1,6 +1,8 @@
1
1
  import type { CandidatePruningAudit } from "@mupt-ai/dari-router/policy-engine";
2
2
 
3
3
  import { interactionConversationPrefix } from "../context/interaction_fingerprint.js";
4
+ import { clampRequestedEffort } from "../context/routing_context.js";
5
+ import { modelMetadataLookup } from "../providers/pi.js";
4
6
  import {
5
7
  buildRoutingContext,
6
8
  derivePrefixLookupHashes,
@@ -134,7 +136,14 @@ export async function prepareRoutedCompletion(args: {
134
136
  }
135
137
  const access = strictProviderAccess(credentials, args.interaction);
136
138
  credentials = access.credentials;
137
- args = { ...args, interaction: access.interaction };
139
+ args = {
140
+ ...args,
141
+ interaction: clampRequestedEffort(
142
+ access.interaction,
143
+ credentials,
144
+ modelMetadataLookup(credentials.model_configs),
145
+ ),
146
+ };
138
147
  credentials = await withProviderCredentials(credentials, args.apiKey, args.clientHarness);
139
148
  const billable = true;
140
149
  const routing = await buildRoutingContext({
@@ -190,6 +199,8 @@ export async function prepareRoutedCompletion(args: {
190
199
  identityHit: routing.identityHit,
191
200
  hits: routing.hits,
192
201
  costEstimates: routing.reportingCostEstimates,
202
+ candidates: candidateResolution.candidates,
203
+ sessionPinKey: sessionPin?.key,
193
204
  pruning: reportedPruning(
194
205
  candidateResolution.pruning,
195
206
  routing.imageCapabilityPrunedModels,
@@ -12,7 +12,7 @@ import {
12
12
  * default:
13
13
  * models:
14
14
  * - anthropic/claude-sonnet-5-5
15
- * - id: openai/gpt-5.5
15
+ * - id: openai/gpt-6.1-sol
16
16
  * thinking: [medium, high]
17
17
  * subscriptions: true # serve with connected Claude Code / ChatGPT plans
18
18
  * sessionPinning: false # route each session and subagent once, then keep its model
@@ -62,12 +62,42 @@ const ROUTER_ID_PATTERN = /^[A-Za-z0-9_-]+$/;
62
62
  const PROVIDER_ID_PATTERN = /^[a-z0-9][a-z0-9.-]*$/;
63
63
  const MODEL_ID_PATTERN = /^[a-z0-9][a-z0-9.-]*\/\S+$/;
64
64
 
65
- export async function loadGatewayConfig(path: string): Promise<GatewayConfig> {
65
+ // Served when no config file is set or found. A model is served once its
66
+ // provider has a credential (a connected subscription or the provider's API
67
+ // key); dari/routing without Auto Routing serves the first available one.
68
+ export const DEFAULT_GATEWAY_CONFIG: GatewayConfig = parseGatewayConfig({
69
+ routers: {
70
+ default: {
71
+ models: [
72
+ "anthropic/claude-sonnet-5-5",
73
+ "anthropic/claude-opus-5-5",
74
+ "anthropic/claude-fable-5-2",
75
+ "anthropic/claude-haiku-5-5",
76
+ "openai/gpt-6.1-sol",
77
+ "openai/gpt-6.1-terra",
78
+ ],
79
+ },
80
+ },
81
+ });
82
+
83
+ const DEFAULT_CONFIG_PATH = "dari-gateway.yaml";
84
+
85
+ /**
86
+ * Load the config at `path`, which must exist. Without a path, load
87
+ * `dari-gateway.yaml` from the working directory when present, and otherwise
88
+ * serve DEFAULT_GATEWAY_CONFIG.
89
+ */
90
+ export async function loadGatewayConfig(path: string | undefined): Promise<GatewayConfig> {
91
+ if (path === undefined) {
92
+ if (!(await Bun.file(DEFAULT_CONFIG_PATH).exists())) return DEFAULT_GATEWAY_CONFIG;
93
+ path = DEFAULT_CONFIG_PATH;
94
+ }
66
95
  const file = Bun.file(path);
67
96
  if (!(await file.exists())) {
68
97
  throw new Error(
69
98
  `Gateway config not found at ${path}. Write one from dari-gateway.example.yaml, `
70
- + "or set DARI_GATEWAY_CONFIG (in the container image, mount it at /config/dari-gateway.yaml).",
99
+ + "or unset DARI_GATEWAY_CONFIG to serve the default router "
100
+ + "(in the container image, mount it at /config/dari-gateway.yaml).",
71
101
  );
72
102
  }
73
103
  const text = await file.text();
@@ -10,9 +10,12 @@ import { installShutdownHandlers, startServer } from "../http/serve.js";
10
10
  import { createAutoRoutingSelectorBackend } from "../strategies/auto_routing_selector.js";
11
11
  import { createPrioritySelectorBackend } from "../strategies/priority_selector.js";
12
12
  import type { SelectorBackend } from "../strategies/routing.js";
13
- import { loadGatewayConfig } from "./config.js";
14
13
  import { createSelfHostedPlatform } from "./platform.js";
15
- import { gatewayKeyIndex, providersWithoutCredentials } from "./resolve.js";
14
+ import { providerForModel } from "@mupt-ai/dari-router/policy-engine";
15
+ import { checkProviderKey } from "../providers/key_check.js";
16
+ import { CANONICAL_MODEL_IDS } from "../models/canonical.js";
17
+ import { DEFAULT_GATEWAY_CONFIG, loadGatewayConfig, type GatewayConfig } from "./config.js";
18
+ import { configuredProviderApiKey, gatewayKeyIndex, providersWithoutCredentials } from "./resolve.js";
16
19
  import { PrefixStore } from "./prefix_store.js";
17
20
  import { SessionPinStore } from "./session_pin_store.js";
18
21
  import { handleSubscriptionRequest, SubscriptionStore } from "./subscriptions/index.js";
@@ -23,7 +26,8 @@ const SUBSCRIPTION_ROUTES = "/v1/organizations/current/credentials";
23
26
  * Start a self-hosted Dari Gateway from environment variables:
24
27
  *
25
28
  * - `DARI_GATEWAY_KEYS` (required): comma-separated bearer keys clients use.
26
- * - `DARI_GATEWAY_CONFIG`: router config file (default `dari-gateway.yaml`).
29
+ * - `DARI_GATEWAY_CONFIG`: router config file. Unset, `dari-gateway.yaml`
30
+ * serves when present, and otherwise the default router.
27
31
  * - `DARI_GATEWAY_DB`: SQLite database for subscriptions and prefix-cache
28
32
  * state (default `dari-gateway.db`). It holds subscription tokens.
29
33
  * - `DARI_AUTO_ROUTING_API_KEY`: choose among a router's models with Dari
@@ -39,7 +43,7 @@ export async function startSelfHostedGateway(env: Record<string, string> = proce
39
43
  if (gatewayKeys.length === 0) {
40
44
  throw new Error("Set DARI_GATEWAY_KEYS to the comma-separated keys clients authenticate with.");
41
45
  }
42
- const config = await loadGatewayConfig(env.DARI_GATEWAY_CONFIG ?? "dari-gateway.yaml");
46
+ const config = await loadGatewayConfig(env.DARI_GATEWAY_CONFIG?.trim() || undefined);
43
47
  const databasePath = env.DARI_GATEWAY_DB ?? "dari-gateway.db";
44
48
  const created = databasePath !== ":memory:" && !existsSync(databasePath);
45
49
  const db = new Database(databasePath, { create: true });
@@ -76,6 +80,8 @@ export async function startSelfHostedGateway(env: Record<string, string> = proce
76
80
  );
77
81
  }
78
82
  }
83
+ await checkConfiguredProviderKeys(config, (provider) =>
84
+ configuredProviderApiKey({ config, providerApiKey, env }, provider));
79
85
  const autoRoutingKey = env.DARI_AUTO_ROUTING_API_KEY?.trim();
80
86
  const selectorBackend: SelectorBackend = autoRoutingKey
81
87
  ? createAutoRoutingSelectorBackend({
@@ -109,7 +115,8 @@ export async function startSelfHostedGateway(env: Record<string, string> = proce
109
115
  "dari-gateway listening",
110
116
  JSON.stringify({
111
117
  hostname,
112
- port,
118
+ // The bound port, so PORT=0 reports the port the OS assigned.
119
+ port: server.port,
113
120
  routers: Object.keys(config.routers),
114
121
  auto_routing: autoRoutingKey ? "dari" : "priority",
115
122
  }),
@@ -117,7 +124,60 @@ export async function startSelfHostedGateway(env: Record<string, string> = proce
117
124
  return { server, close: () => db.close() };
118
125
  }
119
126
 
120
- if (import.meta.main) {
127
+ /**
128
+ * Check each provider API key the routers use before serving: a key its
129
+ * provider rejects fails startup, and one no check could answer is reported.
130
+ */
131
+ async function checkConfiguredProviderKeys(
132
+ config: GatewayConfig,
133
+ providerApiKey: (provider: string) => Promise<string | undefined>,
134
+ ): Promise<void> {
135
+ const providers = new Set(
136
+ Object.values(config.routers).flatMap((router) => router.models.map((model) => providerForModel(model.id))),
137
+ );
138
+ const checks = await Promise.all([...providers].map(async (provider) => {
139
+ const endpoint = config.providers[provider];
140
+ // Endpoints configured without a key variable take no key.
141
+ if (endpoint !== undefined && endpoint.apiKeyEnv === undefined) return null;
142
+ const key = await providerApiKey(provider);
143
+ if (key === undefined) return null;
144
+ return { provider, ...(await checkProviderKey({ provider, key, ...(endpoint ? { endpoint } : {}), fetch })) };
145
+ }));
146
+ const rejected = checks.flatMap((check) => check?.result === "rejected" ? [`${check.provider} (${check.status})`] : []);
147
+ if (rejected.length > 0) {
148
+ throw new Error(
149
+ `Provider API keys were rejected: ${rejected.join(", ")}. Fix or unset each key before starting the gateway.`,
150
+ );
151
+ }
152
+ const unchecked = checks.flatMap((check) => check?.result === "unchecked" ? [check.provider] : []);
153
+ if (unchecked.length > 0) {
154
+ console.warn("dari-gateway could not check these provider API keys", JSON.stringify({ providers: unchecked }));
155
+ }
156
+ }
157
+
158
+ /** Run the gateway as a process: the `dari-gateway` bin and compiled binary. */
159
+ export async function runSelfHostedGateway(): Promise<void> {
160
+ // `--print-config` prints the router config the gateway would serve, as
161
+ // JSON, and exits; `--print-default-config` prints the built-in default.
162
+ // The Dari CLI edits a local gateway's models from them.
163
+ if (process.argv.includes("--print-canonical-models")) {
164
+ console.log(JSON.stringify(CANONICAL_MODEL_IDS));
165
+ return;
166
+ }
167
+ if (process.argv.includes("--print-default-config")) {
168
+ console.log(JSON.stringify(DEFAULT_GATEWAY_CONFIG));
169
+ return;
170
+ }
171
+ if (process.argv.includes("--print-config")) {
172
+ try {
173
+ const config = await loadGatewayConfig(process.env.DARI_GATEWAY_CONFIG?.trim() || undefined);
174
+ console.log(JSON.stringify(config));
175
+ return;
176
+ } catch (error) {
177
+ console.error(`dari-gateway: ${error instanceof Error ? error.message : String(error)}`);
178
+ process.exit(1);
179
+ }
180
+ }
121
181
  // Startup errors are configuration mistakes: print the message, not a stack.
122
182
  const started = await startSelfHostedGateway().catch((error: unknown) => {
123
183
  console.error(`dari-gateway: ${error instanceof Error ? error.message : String(error)}`);
@@ -125,3 +185,5 @@ if (import.meta.main) {
125
185
  });
126
186
  installShutdownHandlers(started.server, async () => started.close());
127
187
  }
188
+
189
+ if (import.meta.main) await runSelfHostedGateway();
@@ -86,7 +86,7 @@ export async function resolveSelfHostedRouter(
86
86
  if (subscription !== null && deps.subscriptions.list(subscription).length > 0) {
87
87
  exhausted.add(subscription);
88
88
  }
89
- const apiKey = await providerApiKey(deps, provider);
89
+ const apiKey = await configuredProviderApiKey(deps, provider);
90
90
  credentials.set(provider, apiKey ? { key: apiKey, source: "user" } : null);
91
91
  }
92
92
 
@@ -154,7 +154,7 @@ export async function resolveSelfHostedRouter(
154
154
 
155
155
  // Configured endpoints read their own key variable, or send a placeholder
156
156
  // when they take none; known providers use their standard variable.
157
- async function providerApiKey(
157
+ export async function configuredProviderApiKey(
158
158
  deps: Pick<SelfHostedResolverDeps, "config" | "providerApiKey" | "env">,
159
159
  provider: string,
160
160
  ): Promise<string | undefined> {
@@ -226,7 +226,7 @@ export async function providersWithoutCredentials(
226
226
  : [];
227
227
  const connected = plans.some((plan) => plan !== null && deps.subscriptions.list(plan).length > 0);
228
228
  if (connected) continue;
229
- const apiKey = await providerApiKey(deps, provider);
229
+ const apiKey = await configuredProviderApiKey(deps, provider);
230
230
  if (apiKey === undefined) missing.push(provider);
231
231
  }
232
232
  return missing;
@@ -190,6 +190,8 @@ function selectorContentField(
190
190
  if (part.type === "tool_reference") {
191
191
  return { type: "text", text: `Referenced tool: ${part.toolName}` };
192
192
  }
193
+ // The selector model is text-only; it sees a marker, not the document bytes.
194
+ if (part.type === "document") return { type: "text", text: "(PDF document omitted from selector input)" };
193
195
  return imagePart(part.url, part.detail);
194
196
  }),
195
197
  };
@@ -11,6 +11,7 @@ import {
11
11
  type ChainHits,
12
12
  type PrefixHit,
13
13
  type PricingLookup,
14
+ type RoutingCandidate,
14
15
  type RoutingDecision,
15
16
  type RoutingStrategy,
16
17
  } from "@mupt-ai/dari-router/policy-engine";
@@ -25,7 +26,7 @@ import {
25
26
  type ProviderIdentity,
26
27
  } from "../interaction/types.js";
27
28
  import { captureOperationalError } from "../observability/errors.js";
28
- import { gatewayPlatform, type RequestTags } from "../platform/platform.js";
29
+ import { gatewayPlatform, type RequestRecordRouter, type RequestTags } from "../platform/platform.js";
29
30
  import type {
30
31
  ProviderAttemptRecord,
31
32
  ProviderAttemptReport,
@@ -179,6 +180,10 @@ export class RequestReporter {
179
180
  identityHit?: PrefixHit;
180
181
  hits: ChainHits;
181
182
  costEstimates: CandidateCostEstimate[];
183
+ // The model and reasoning pairs this request could be routed to.
184
+ candidates: readonly RoutingCandidate[];
185
+ // The session pin this request routed under, when the router pins.
186
+ sessionPinKey?: string;
182
187
  // Cache-aware selector-candidate pruning outcome, plus any pre-strategy
183
188
  // capability exclusions. Null when custom routing configuration cannot
184
189
  // produce candidates.
@@ -603,6 +608,9 @@ export class RequestReporter {
603
608
  };
604
609
 
605
610
  gatewayPlatform().recordRequest({
611
+ router: requestRecordRouter(a.credentials),
612
+ candidates: a.candidates.map(({ model, reasoningEffort }) => ({ model, reasoningEffort })),
613
+ ...(a.sessionPinKey === undefined ? {} : { sessionPinKey: a.sessionPinKey }),
606
614
  row: ingestPayload,
607
615
  summary: {
608
616
  request_id: a.requestId,
@@ -786,3 +794,28 @@ function usageIngest(
786
794
  pricing_known: pricingKnown,
787
795
  };
788
796
  }
797
+
798
+ function requestRecordRouter(router: ResolvedRouter): RequestRecordRouter {
799
+ return {
800
+ enabledModels: [...router.enabled_models],
801
+ thinkingLevels: Object.fromEntries(
802
+ router.enabled_models.map((model) => [
803
+ model,
804
+ [
805
+ ...(router.model_thinking_levels?.[model]
806
+ ?? router.model_configs?.[model]?.supported_thinking_levels
807
+ ?? []),
808
+ ],
809
+ ]),
810
+ ),
811
+ defaultThinkingLevels: Object.fromEntries(
812
+ router.enabled_models.flatMap((model) => {
813
+ const level = router.model_configs?.[model]?.default_thinking_level;
814
+ return level ? [[model, level]] : [];
815
+ }),
816
+ ),
817
+ strict: router.strict === true,
818
+ speculativeRouting: router.speculative_routing === true,
819
+ sessionPinning: router.session_pinning === true,
820
+ };
821
+ }