@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
@@ -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 : "";
@@ -5,6 +5,7 @@ import { parseDataImageUrl } from "../../interaction/image.js";
5
5
  import { decodeUntrustedProviderContinuationState } from "../../interaction/provider_continuation_state.js";
6
6
  import { CANONICAL_ROUTER_MODEL, explicitModelId, unsupportedModelMessage } from "./router_model.js";
7
7
  import { isProtocolToolCallId, TOOL_CALL_ID_MAX_LENGTH, toolsByWireName, } from "../../interaction/tool_identity.js";
8
+ import { parsePdfDataUrl } from "../../interaction/document.js";
8
9
  const UNSUPPORTED_INPUT_ITEM_TYPES = new Set([
9
10
  "additional_tools",
10
11
  "agent_message",
@@ -388,6 +389,13 @@ function responseContent(value, param) {
388
389
  ...(detail !== undefined ? { detail } : {}),
389
390
  };
390
391
  }
392
+ if (part.type === "input_file") {
393
+ const data = typeof part.file_data === "string" ? parsePdfDataUrl(part.file_data) : undefined;
394
+ if (data === undefined) {
395
+ throw new HttpError(400, "Responses input_file parts must carry a base64 PDF data URL in file_data; file_id and file_url are not supported.", "unsupported_file_source", `${param}.${index}`);
396
+ }
397
+ return { type: "document", mimeType: "application/pdf", data };
398
+ }
391
399
  throw new HttpError(400, `Responses content part type '${String(part.type ?? "unknown")}' is not supported.`, "unsupported_response_content", `${param}.${index}.type`);
392
400
  });
393
401
  }
@@ -51,6 +51,13 @@ export function anthropicContent(output) {
51
51
  content.push({ type: "text", text: `Referenced tool: ${part.toolName}` });
52
52
  continue;
53
53
  }
54
+ if (part.type === "document") {
55
+ content.push({
56
+ type: "document",
57
+ source: { type: "base64", media_type: part.mimeType, data: part.data },
58
+ });
59
+ continue;
60
+ }
54
61
  const image = parseDataImageUrl(part.url);
55
62
  if (!image)
56
63
  throw new Error("Anthropic output images must use base64 data URLs.");
@@ -0,0 +1,31 @@
1
+ /**
2
+ * - `valid`: the provider accepted the key.
3
+ * - `rejected`: the provider refused it (401 or 403).
4
+ * - `unchecked`: no answer either way, such as a network error, an outage, or
5
+ * a provider without a known check. Callers treat this as not rejected so a
6
+ * provider outage never blocks a key.
7
+ */
8
+ export type ProviderKeyCheckResult = {
9
+ result: "valid";
10
+ } | {
11
+ result: "rejected";
12
+ status: number;
13
+ } | {
14
+ result: "unchecked";
15
+ };
16
+ export type ProviderKeyEndpoint = {
17
+ api: string;
18
+ 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 declare function checkProviderKey(args: {
27
+ provider: string;
28
+ key: string;
29
+ endpoint?: ProviderKeyEndpoint;
30
+ fetch: typeof fetch;
31
+ }): Promise<ProviderKeyCheckResult>;
@@ -0,0 +1,54 @@
1
+ import { getBuiltinModels } from "@mupt-ai/pi-ai/providers/all";
2
+ const CHECK_TIMEOUT_MS = 10_000;
3
+ /**
4
+ * Ask a provider whether an API key works, with the cheapest authenticated
5
+ * request its wire API offers: list models, or, where listing models needs
6
+ * no key, an endpoint that does. The self-hosted gateway runs this at startup
7
+ * and the hosted control plane before it saves a key.
8
+ */
9
+ export async function checkProviderKey(args) {
10
+ const check = keyCheckRequest(args.provider, args.key, args.endpoint);
11
+ if (check === null)
12
+ return { result: "unchecked" };
13
+ try {
14
+ const response = await args.fetch(check.url, {
15
+ headers: check.headers,
16
+ signal: AbortSignal.timeout(CHECK_TIMEOUT_MS),
17
+ });
18
+ await response.body?.cancel();
19
+ if (response.status === 401 || response.status === 403) {
20
+ return { result: "rejected", status: response.status };
21
+ }
22
+ return response.ok ? { result: "valid" } : { result: "unchecked" };
23
+ }
24
+ catch {
25
+ return { result: "unchecked" };
26
+ }
27
+ }
28
+ function keyCheckRequest(provider, key, endpoint) {
29
+ const bearer = { authorization: `Bearer ${key}` };
30
+ if (endpoint === undefined) {
31
+ if (provider === "openrouter")
32
+ return { url: "https://openrouter.ai/api/v1/key", headers: bearer };
33
+ if (provider === "vercel-ai-gateway")
34
+ return { url: "https://ai-gateway.vercel.sh/v1/credits", headers: bearer };
35
+ }
36
+ const target = endpoint
37
+ ?? getBuiltinModels(provider)?.[0];
38
+ if (target === undefined || !URL.canParse(target.baseUrl))
39
+ return null;
40
+ const base = target.baseUrl.replace(/\/+$/, "");
41
+ switch (target.api) {
42
+ case "anthropic-messages":
43
+ return { url: `${base}/v1/models`, headers: { "x-api-key": key, "anthropic-version": "2023-06-01" } };
44
+ case "google-generative-ai":
45
+ return { url: `${base}/models`, headers: { "x-goog-api-key": key } };
46
+ case "mistral-conversations":
47
+ return { url: `${base}/v1/models`, headers: bearer };
48
+ case "openai-completions":
49
+ case "openai-responses":
50
+ return { url: `${base}/models`, headers: bearer };
51
+ default:
52
+ return null;
53
+ }
54
+ }
@@ -4,6 +4,8 @@ type ModelCapabilityMetadata = {
4
4
  export declare const BEDROCK_API = "bedrock-converse-stream";
5
5
  export declare function isBedrockApi(api: unknown): boolean;
6
6
  export declare function modelSupportsImageInput(metadata: ModelCapabilityMetadata): boolean;
7
+ export declare function apiSupportsDocumentInput(api: string): boolean;
8
+ export declare function modelSupportsDocumentInput(modelId: string, metadata: ModelCapabilityMetadata): boolean;
7
9
  export declare function providerApiCanSerializeResponseFormat(metadata: ModelCapabilityMetadata): boolean;
8
10
  export declare function isOpenAIResponsesApi(api: unknown): boolean;
9
11
  export declare function modelSupportsReasoningConfigurationUpdate(metadata: ModelCapabilityMetadata): boolean;
@@ -6,6 +6,21 @@ export function isBedrockApi(api) {
6
6
  export function modelSupportsImageInput(metadata) {
7
7
  return Array.isArray(metadata.model.input) && metadata.model.input.includes("image");
8
8
  }
9
+ /* PDFs are sent natively only where the model owner documents PDF input on
10
+ that API. The same API can serve other owners (xAI and Meta on Responses,
11
+ GPT on Bedrock) whose PDF support is unverified, so both must match. */
12
+ const DOCUMENT_INPUT_APIS_BY_OWNER = {
13
+ anthropic: new Set(["anthropic-messages", "bedrock-converse-stream"]),
14
+ openai: new Set(["openai-responses", "azure-openai-responses", "openai-codex-responses"]),
15
+ };
16
+ export function apiSupportsDocumentInput(api) {
17
+ return Object.values(DOCUMENT_INPUT_APIS_BY_OWNER).some((apis) => apis.has(api));
18
+ }
19
+ export function modelSupportsDocumentInput(modelId, metadata) {
20
+ const owner = modelId.split("/", 1)[0] ?? "";
21
+ const api = metadata.model.api;
22
+ return typeof api === "string" && DOCUMENT_INPUT_APIS_BY_OWNER[owner]?.has(api) === true;
23
+ }
9
24
  export function providerApiCanSerializeResponseFormat(metadata) {
10
25
  return (metadata.model.api === "openai-responses" ||
11
26
  metadata.model.api === "azure-openai-responses" ||
@@ -5,6 +5,7 @@ import { parseDataImageUrl } from "../interaction/image.js";
5
5
  import { providerToolCallId, toolWireName } from "../interaction/tool_identity.js";
6
6
  import { compatibleProviderContinuation, sameProviderIdentity, } from "../interaction/provider_continuation_state.js";
7
7
  import { isOpenAIResponsesApi } from "./model_capabilities.js";
8
+ import { apiSupportsDocumentInput } from "./model_capabilities.js";
8
9
  const UNKNOWN_SOURCE = {
9
10
  provider: "dari",
10
11
  api: "interaction-history",
@@ -72,7 +73,7 @@ export function buildContext(request, target) {
72
73
  sawConversationItem = true;
73
74
  if (item.type === "message" && item.role === "user") {
74
75
  flushAssistant();
75
- appendUserMessage(messages, userContent(item.content, item.contentShape), timestamp);
76
+ appendUserMessage(messages, userContent(item.content, item.contentShape, target), timestamp);
76
77
  continue;
77
78
  }
78
79
  if (item.type === "tool_result") {
@@ -252,10 +253,10 @@ function thinkingContent(item, continuation) {
252
253
  redacted: true,
253
254
  };
254
255
  }
255
- function userContent(content, shape) {
256
+ function userContent(content, shape, target) {
256
257
  if (shape !== "parts")
257
258
  return content.filter((part) => part.type === "text").map((part) => part.text).join("");
258
- return content.map(contentToPi);
259
+ return content.map((part) => contentToPi(part, target));
259
260
  }
260
261
  function appendPositionalNote(messages, text, timestamp) {
261
262
  const previous = messages.at(-1);
@@ -290,13 +291,20 @@ function toolResultContent(content, target) {
290
291
  const filtered = target.api === "anthropic-messages"
291
292
  ? content.filter((part) => part.type !== "tool_reference")
292
293
  : content;
293
- return filtered.map(contentToPi);
294
+ return filtered.map((part) => contentToPi(part, target));
294
295
  }
295
- function contentToPi(content) {
296
+ function contentToPi(content, target) {
296
297
  if (content.type === "text")
297
298
  return { type: "text", text: content.text };
298
299
  if (content.type === "tool_reference")
299
300
  return { type: "text", text: `Referenced tool: ${content.toolName}` };
301
+ if (content.type === "document") {
302
+ // Routing keeps PDFs on document-capable models; this guards direct callers.
303
+ if (!apiSupportsDocumentInput(target.api)) {
304
+ throw new HttpError(400, `PDF document input is not supported by the ${target.api} API.`, "unsupported_input_modality", "input.document");
305
+ }
306
+ return { type: "document", mimeType: content.mimeType, data: content.data };
307
+ }
300
308
  const image = parseDataImageUrl(content.url);
301
309
  if (!image) {
302
310
  throw new HttpError(400, "Only base64 data URLs are supported for routed image input.", "invalid_request_error", "input.image_url");
@@ -1,4 +1,6 @@
1
1
  import { interactionConversationPrefix } from "../context/interaction_fingerprint.js";
2
+ import { clampRequestedEffort } from "../context/routing_context.js";
3
+ import { modelMetadataLookup } from "../providers/pi.js";
2
4
  import { buildRoutingContext, derivePrefixLookupHashes, } from "../context/routing_context.js";
3
5
  import { isBackgroundRequest } from "../interaction/types.js";
4
6
  import { executionProviderForModel } from "../models/execution_provider.js";
@@ -84,7 +86,10 @@ export async function prepareRoutedCompletion(args) {
84
86
  }
85
87
  const access = strictProviderAccess(credentials, args.interaction);
86
88
  credentials = access.credentials;
87
- args = { ...args, interaction: access.interaction };
89
+ args = {
90
+ ...args,
91
+ interaction: clampRequestedEffort(access.interaction, credentials, modelMetadataLookup(credentials.model_configs)),
92
+ };
88
93
  credentials = await withProviderCredentials(credentials, args.apiKey, args.clientHarness);
89
94
  const billable = true;
90
95
  const routing = await buildRoutingContext({
@@ -138,6 +143,8 @@ export async function prepareRoutedCompletion(args) {
138
143
  identityHit: routing.identityHit,
139
144
  hits: routing.hits,
140
145
  costEstimates: routing.reportingCostEstimates,
146
+ candidates: candidateResolution.candidates,
147
+ sessionPinKey: sessionPin?.key,
141
148
  pruning: reportedPruning(candidateResolution.pruning, routing.imageCapabilityPrunedModels),
142
149
  pricing: routing.pricing,
143
150
  chainByModel: routing.prefix.chainByModel,
@@ -7,7 +7,7 @@ import { type ReasoningEffort } from "@mupt-ai/dari-router/policy-engine";
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
  * subscriptions: true # serve with connected Claude Code / ChatGPT plans
13
13
  * sessionPinning: false # route each session and subagent once, then keep its model
@@ -41,5 +41,11 @@ export type RouterModelConfig = {
41
41
  id: string;
42
42
  thinking?: ReasoningEffort[];
43
43
  };
44
- export declare function loadGatewayConfig(path: string): Promise<GatewayConfig>;
44
+ export declare const DEFAULT_GATEWAY_CONFIG: GatewayConfig;
45
+ /**
46
+ * Load the config at `path`, which must exist. Without a path, load
47
+ * `dari-gateway.yaml` from the working directory when present, and otherwise
48
+ * serve DEFAULT_GATEWAY_CONFIG.
49
+ */
50
+ export declare function loadGatewayConfig(path: string | undefined): Promise<GatewayConfig>;
45
51
  export declare function parseGatewayConfig(raw: unknown): GatewayConfig;
@@ -2,11 +2,40 @@ import { isReasoningEffort, isRecord, } from "@mupt-ai/dari-router/policy-engine
2
2
  const ROUTER_ID_PATTERN = /^[A-Za-z0-9_-]+$/;
3
3
  const PROVIDER_ID_PATTERN = /^[a-z0-9][a-z0-9.-]*$/;
4
4
  const MODEL_ID_PATTERN = /^[a-z0-9][a-z0-9.-]*\/\S+$/;
5
+ // Served when no config file is set or found. A model is served once its
6
+ // provider has a credential (a connected subscription or the provider's API
7
+ // key); dari/routing without Auto Routing serves the first available one.
8
+ export const DEFAULT_GATEWAY_CONFIG = parseGatewayConfig({
9
+ routers: {
10
+ default: {
11
+ models: [
12
+ "anthropic/claude-sonnet-5-5",
13
+ "anthropic/claude-opus-5-5",
14
+ "anthropic/claude-fable-5-2",
15
+ "anthropic/claude-haiku-5-5",
16
+ "openai/gpt-6.1-sol",
17
+ "openai/gpt-6.1-terra",
18
+ ],
19
+ },
20
+ },
21
+ });
22
+ const DEFAULT_CONFIG_PATH = "dari-gateway.yaml";
23
+ /**
24
+ * Load the config at `path`, which must exist. Without a path, load
25
+ * `dari-gateway.yaml` from the working directory when present, and otherwise
26
+ * serve DEFAULT_GATEWAY_CONFIG.
27
+ */
5
28
  export async function loadGatewayConfig(path) {
29
+ if (path === undefined) {
30
+ if (!(await Bun.file(DEFAULT_CONFIG_PATH).exists()))
31
+ return DEFAULT_GATEWAY_CONFIG;
32
+ path = DEFAULT_CONFIG_PATH;
33
+ }
6
34
  const file = Bun.file(path);
7
35
  if (!(await file.exists())) {
8
36
  throw new Error(`Gateway config not found at ${path}. Write one from dari-gateway.example.yaml, `
9
- + "or set DARI_GATEWAY_CONFIG (in the container image, mount it at /config/dari-gateway.yaml).");
37
+ + "or unset DARI_GATEWAY_CONFIG to serve the default router "
38
+ + "(in the container image, mount it at /config/dari-gateway.yaml).");
10
39
  }
11
40
  const text = await file.text();
12
41
  const raw = path.endsWith(".json") ? JSON.parse(text) : Bun.YAML.parse(text);
@@ -3,7 +3,8 @@
3
3
  * Start a self-hosted Dari Gateway from environment variables:
4
4
  *
5
5
  * - `DARI_GATEWAY_KEYS` (required): comma-separated bearer keys clients use.
6
- * - `DARI_GATEWAY_CONFIG`: router config file (default `dari-gateway.yaml`).
6
+ * - `DARI_GATEWAY_CONFIG`: router config file. Unset, `dari-gateway.yaml`
7
+ * serves when present, and otherwise the default router.
7
8
  * - `DARI_GATEWAY_DB`: SQLite database for subscriptions and prefix-cache
8
9
  * state (default `dari-gateway.db`). It holds subscription tokens.
9
10
  * - `DARI_AUTO_ROUTING_API_KEY`: choose among a router's models with Dari
@@ -15,3 +16,5 @@ export declare function startSelfHostedGateway(env?: Record<string, string>): Pr
15
16
  server: import("../http/http2_server.js").Http2RoutingServer | Bun.Server<undefined>;
16
17
  close: () => void;
17
18
  }>;
19
+ /** Run the gateway as a process: the `dari-gateway` bin and compiled binary. */
20
+ export declare function runSelfHostedGateway(): Promise<void>;
@@ -8,9 +8,12 @@ import { createRequestHandler } from "../http/handler.js";
8
8
  import { installShutdownHandlers, startServer } from "../http/serve.js";
9
9
  import { createAutoRoutingSelectorBackend } from "../strategies/auto_routing_selector.js";
10
10
  import { createPrioritySelectorBackend } from "../strategies/priority_selector.js";
11
- import { loadGatewayConfig } from "./config.js";
12
11
  import { createSelfHostedPlatform } from "./platform.js";
13
- import { gatewayKeyIndex, providersWithoutCredentials } from "./resolve.js";
12
+ import { providerForModel } from "@mupt-ai/dari-router/policy-engine";
13
+ import { checkProviderKey } from "../providers/key_check.js";
14
+ import { CANONICAL_MODEL_IDS } from "../models/canonical.js";
15
+ import { DEFAULT_GATEWAY_CONFIG, loadGatewayConfig } from "./config.js";
16
+ import { configuredProviderApiKey, gatewayKeyIndex, providersWithoutCredentials } from "./resolve.js";
14
17
  import { PrefixStore } from "./prefix_store.js";
15
18
  import { SessionPinStore } from "./session_pin_store.js";
16
19
  import { handleSubscriptionRequest, SubscriptionStore } from "./subscriptions/index.js";
@@ -19,7 +22,8 @@ const SUBSCRIPTION_ROUTES = "/v1/organizations/current/credentials";
19
22
  * Start a self-hosted Dari Gateway from environment variables:
20
23
  *
21
24
  * - `DARI_GATEWAY_KEYS` (required): comma-separated bearer keys clients use.
22
- * - `DARI_GATEWAY_CONFIG`: router config file (default `dari-gateway.yaml`).
25
+ * - `DARI_GATEWAY_CONFIG`: router config file. Unset, `dari-gateway.yaml`
26
+ * serves when present, and otherwise the default router.
23
27
  * - `DARI_GATEWAY_DB`: SQLite database for subscriptions and prefix-cache
24
28
  * state (default `dari-gateway.db`). It holds subscription tokens.
25
29
  * - `DARI_AUTO_ROUTING_API_KEY`: choose among a router's models with Dari
@@ -35,7 +39,7 @@ export async function startSelfHostedGateway(env = process.env) {
35
39
  if (gatewayKeys.length === 0) {
36
40
  throw new Error("Set DARI_GATEWAY_KEYS to the comma-separated keys clients authenticate with.");
37
41
  }
38
- const config = await loadGatewayConfig(env.DARI_GATEWAY_CONFIG ?? "dari-gateway.yaml");
42
+ const config = await loadGatewayConfig(env.DARI_GATEWAY_CONFIG?.trim() || undefined);
39
43
  const databasePath = env.DARI_GATEWAY_DB ?? "dari-gateway.db";
40
44
  const created = databasePath !== ":memory:" && !existsSync(databasePath);
41
45
  const db = new Database(databasePath, { create: true });
@@ -69,6 +73,7 @@ export async function startSelfHostedGateway(env = process.env) {
69
73
  console.warn("dari-gateway router has providers without credentials; their models are not served", JSON.stringify({ router: routerId, providers: missing }));
70
74
  }
71
75
  }
76
+ await checkConfiguredProviderKeys(config, (provider) => configuredProviderApiKey({ config, providerApiKey, env }, provider));
72
77
  const autoRoutingKey = env.DARI_AUTO_ROUTING_API_KEY?.trim();
73
78
  const selectorBackend = autoRoutingKey
74
79
  ? createAutoRoutingSelectorBackend({
@@ -99,13 +104,62 @@ export async function startSelfHostedGateway(env = process.env) {
99
104
  const server = startServer(port, handler, { hostname });
100
105
  console.info("dari-gateway listening", JSON.stringify({
101
106
  hostname,
102
- port,
107
+ // The bound port, so PORT=0 reports the port the OS assigned.
108
+ port: server.port,
103
109
  routers: Object.keys(config.routers),
104
110
  auto_routing: autoRoutingKey ? "dari" : "priority",
105
111
  }));
106
112
  return { server, close: () => db.close() };
107
113
  }
108
- if (import.meta.main) {
114
+ /**
115
+ * Check each provider API key the routers use before serving: a key its
116
+ * provider rejects fails startup, and one no check could answer is reported.
117
+ */
118
+ async function checkConfiguredProviderKeys(config, providerApiKey) {
119
+ const providers = new Set(Object.values(config.routers).flatMap((router) => router.models.map((model) => providerForModel(model.id))));
120
+ const checks = await Promise.all([...providers].map(async (provider) => {
121
+ const endpoint = config.providers[provider];
122
+ // Endpoints configured without a key variable take no key.
123
+ if (endpoint !== undefined && endpoint.apiKeyEnv === undefined)
124
+ return null;
125
+ const key = await providerApiKey(provider);
126
+ if (key === undefined)
127
+ return null;
128
+ return { provider, ...(await checkProviderKey({ provider, key, ...(endpoint ? { endpoint } : {}), fetch })) };
129
+ }));
130
+ const rejected = checks.flatMap((check) => check?.result === "rejected" ? [`${check.provider} (${check.status})`] : []);
131
+ if (rejected.length > 0) {
132
+ throw new Error(`Provider API keys were rejected: ${rejected.join(", ")}. Fix or unset each key before starting the gateway.`);
133
+ }
134
+ const unchecked = checks.flatMap((check) => check?.result === "unchecked" ? [check.provider] : []);
135
+ if (unchecked.length > 0) {
136
+ console.warn("dari-gateway could not check these provider API keys", JSON.stringify({ providers: unchecked }));
137
+ }
138
+ }
139
+ /** Run the gateway as a process: the `dari-gateway` bin and compiled binary. */
140
+ export async function runSelfHostedGateway() {
141
+ // `--print-config` prints the router config the gateway would serve, as
142
+ // JSON, and exits; `--print-default-config` prints the built-in default.
143
+ // The Dari CLI edits a local gateway's models from them.
144
+ if (process.argv.includes("--print-canonical-models")) {
145
+ console.log(JSON.stringify(CANONICAL_MODEL_IDS));
146
+ return;
147
+ }
148
+ if (process.argv.includes("--print-default-config")) {
149
+ console.log(JSON.stringify(DEFAULT_GATEWAY_CONFIG));
150
+ return;
151
+ }
152
+ if (process.argv.includes("--print-config")) {
153
+ try {
154
+ const config = await loadGatewayConfig(process.env.DARI_GATEWAY_CONFIG?.trim() || undefined);
155
+ console.log(JSON.stringify(config));
156
+ return;
157
+ }
158
+ catch (error) {
159
+ console.error(`dari-gateway: ${error instanceof Error ? error.message : String(error)}`);
160
+ process.exit(1);
161
+ }
162
+ }
109
163
  // Startup errors are configuration mistakes: print the message, not a stack.
110
164
  const started = await startSelfHostedGateway().catch((error) => {
111
165
  console.error(`dari-gateway: ${error instanceof Error ? error.message : String(error)}`);
@@ -113,3 +167,5 @@ if (import.meta.main) {
113
167
  });
114
168
  installShutdownHandlers(started.server, async () => started.close());
115
169
  }
170
+ if (import.meta.main)
171
+ await runSelfHostedGateway();
@@ -25,6 +25,7 @@ export type SelfHostedResolverDeps = {
25
25
  * neither are left out.
26
26
  */
27
27
  export declare function resolveSelfHostedRouter(deps: SelfHostedResolverDeps, request: ResolveRouterRequest): Promise<ResolvedRouter>;
28
+ export declare function configuredProviderApiKey(deps: Pick<SelfHostedResolverDeps, "config" | "providerApiKey" | "env">, provider: string): Promise<string | undefined>;
28
29
  export declare function gatewayKeyIndex(keys: readonly string[], presented: string): number | null;
29
30
  /**
30
31
  * Providers a router lists that no credential can serve: no API key and no
@@ -44,7 +44,7 @@ export async function resolveSelfHostedRouter(deps, request) {
44
44
  if (subscription !== null && deps.subscriptions.list(subscription).length > 0) {
45
45
  exhausted.add(subscription);
46
46
  }
47
- const apiKey = await providerApiKey(deps, provider);
47
+ const apiKey = await configuredProviderApiKey(deps, provider);
48
48
  credentials.set(provider, apiKey ? { key: apiKey, source: "user" } : null);
49
49
  }
50
50
  const models = router.models.filter((model) => credentials.get(providerForModel(model.id)) != null);
@@ -112,7 +112,7 @@ export async function resolveSelfHostedRouter(deps, request) {
112
112
  }
113
113
  // Configured endpoints read their own key variable, or send a placeholder
114
114
  // when they take none; known providers use their standard variable.
115
- async function providerApiKey(deps, provider) {
115
+ export async function configuredProviderApiKey(deps, provider) {
116
116
  const endpoint = deps.config.providers[provider];
117
117
  if (endpoint === undefined)
118
118
  return (await deps.providerApiKey(provider))?.trim() || undefined;
@@ -172,7 +172,7 @@ export async function providersWithoutCredentials(deps, router) {
172
172
  const connected = plans.some((plan) => plan !== null && deps.subscriptions.list(plan).length > 0);
173
173
  if (connected)
174
174
  continue;
175
- const apiKey = await providerApiKey(deps, provider);
175
+ const apiKey = await configuredProviderApiKey(deps, provider);
176
176
  if (apiKey === undefined)
177
177
  missing.push(provider);
178
178
  }
@@ -145,6 +145,9 @@ function selectorContentField(content, shape, imagePart) {
145
145
  if (part.type === "tool_reference") {
146
146
  return { type: "text", text: `Referenced tool: ${part.toolName}` };
147
147
  }
148
+ // The selector model is text-only; it sees a marker, not the document bytes.
149
+ if (part.type === "document")
150
+ return { type: "text", text: "(PDF document omitted from selector input)" };
148
151
  return imagePart(part.url, part.detail);
149
152
  }),
150
153
  };
@@ -1,4 +1,4 @@
1
- import { type CandidateCostEstimate, type CandidatePruningAudit, type ChainHits, type PrefixHit, type PricingLookup, type RoutingDecision, type RoutingStrategy } from "@mupt-ai/dari-router/policy-engine";
1
+ import { type CandidateCostEstimate, type CandidatePruningAudit, type ChainHits, type PrefixHit, type PricingLookup, type RoutingCandidate, type RoutingDecision, type RoutingStrategy } from "@mupt-ai/dari-router/policy-engine";
2
2
  import type { ImageCapabilityPrunedModel } from "../context/routing_context.js";
3
3
  import { type InteractionRequest, type ProviderIdentity } from "../interaction/types.js";
4
4
  import { type RequestTags } from "../platform/platform.js";
@@ -47,6 +47,8 @@ export declare class RequestReporter {
47
47
  identityHit?: PrefixHit;
48
48
  hits: ChainHits;
49
49
  costEstimates: CandidateCostEstimate[];
50
+ candidates: readonly RoutingCandidate[];
51
+ sessionPinKey?: string;
50
52
  pruning: ReportedCandidatePruning | null;
51
53
  pricing: PricingLookup | null;
52
54
  chainByModel: ReadonlyMap<string, string[]>;
@@ -414,6 +414,9 @@ export class RequestReporter {
414
414
  touched_prefix_hash: touchedHash,
415
415
  };
416
416
  gatewayPlatform().recordRequest({
417
+ router: requestRecordRouter(a.credentials),
418
+ candidates: a.candidates.map(({ model, reasoningEffort }) => ({ model, reasoningEffort })),
419
+ ...(a.sessionPinKey === undefined ? {} : { sessionPinKey: a.sessionPinKey }),
417
420
  row: ingestPayload,
418
421
  summary: {
419
422
  request_id: a.requestId,
@@ -583,3 +586,23 @@ function usageIngest(usage, pricingKnown) {
583
586
  pricing_known: pricingKnown,
584
587
  };
585
588
  }
589
+ function requestRecordRouter(router) {
590
+ return {
591
+ enabledModels: [...router.enabled_models],
592
+ thinkingLevels: Object.fromEntries(router.enabled_models.map((model) => [
593
+ model,
594
+ [
595
+ ...(router.model_thinking_levels?.[model]
596
+ ?? router.model_configs?.[model]?.supported_thinking_levels
597
+ ?? []),
598
+ ],
599
+ ])),
600
+ defaultThinkingLevels: Object.fromEntries(router.enabled_models.flatMap((model) => {
601
+ const level = router.model_configs?.[model]?.default_thinking_level;
602
+ return level ? [[model, level]] : [];
603
+ })),
604
+ strict: router.strict === true,
605
+ speculativeRouting: router.speculative_routing === true,
606
+ sessionPinning: router.session_pinning === true,
607
+ };
608
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mupt-ai/dari-gateway",
3
- "version": "0.3.3",
3
+ "version": "0.3.5",
4
4
  "description": "Self-hostable LLM gateway for coding agents and APIs: one endpoint for Anthropic Messages, OpenAI Chat Completions, and OpenAI Responses, with subscription pooling, failover, and prompt-cache-aware routing.",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {
@@ -42,6 +42,7 @@
42
42
  ],
43
43
  "scripts": {
44
44
  "start": "bun run src/self_host/main.ts",
45
+ "compile": "bun scripts/compile.ts",
45
46
  "build": "rm -rf dist && tsc -p tsconfig.build.json",
46
47
  "typecheck": "tsc --noEmit",
47
48
  "test": "bun test",
@@ -50,7 +51,7 @@
50
51
  "dependencies": {
51
52
  "@aws-sdk/client-sts": "3.1063.0",
52
53
  "@mupt-ai/dari-router": "0.2.5",
53
- "@mupt-ai/pi-ai": "1.4.0-mupt.2"
54
+ "@mupt-ai/pi-ai": "1.4.0-mupt.3"
54
55
  },
55
56
  "devDependencies": {
56
57
  "@types/bun": "^1.3.4",
@@ -22,6 +22,7 @@ export type FingerprintContent =
22
22
  url: string;
23
23
  detail?: "auto" | "low" | "high" | "original";
24
24
  }
25
+ | { type: "document"; mimeType: "application/pdf"; data: string }
25
26
  | { type: "tool_reference"; toolName: string }
26
27
  >
27
28
  | null;
@@ -150,6 +151,9 @@ export function interactionFingerprintContent(
150
151
  toolName: part.toolName,
151
152
  };
152
153
  }
154
+ if (part.type === "document") {
155
+ return { type: "document" as const, mimeType: part.mimeType, data: part.data };
156
+ }
153
157
  return {
154
158
  type: "image" as const,
155
159
  url: part.url,