@mupt-ai/dari-gateway 0.1.0 → 0.2.0

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.
package/README.md CHANGED
@@ -111,7 +111,14 @@ const handler = createRequestHandler({ platform, selectorBackend: createPriority
111
111
  Bun.serve({ port: 8080, fetch: handler, idleTimeout: 0 });
112
112
  ```
113
113
 
114
- `src/self_host/` is the reference platform.
114
+ The root entry needs no Bun APIs, so any runtime with web-standard `fetch`, `Request`, and `Response` can serve the handler. `startServer` and `startSelfHostedGateway` (Bun.serve and bun:sqlite) are in `@mupt-ai/dari-gateway/bun`. `src/self_host/` is the reference platform.
115
+
116
+ Platforms can also:
117
+
118
+ - implement `resolveProviderCredential({ provider, model, routerId, organizationId, apiKey })` to supply a per-request `{ apiKey, baseUrl?, headers? }`, such as a tenant's own key, in place of the router's resolved key;
119
+ - resolve routers with `strict: true` to serve only the requested `dari/model/<id>` at the requested thinking level, with no selector, lease, fallback, or personal subscription;
120
+ - 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;
121
+ - attribute cost with `X-Dari-Tags: run=r1,trial=3`, which is parsed into `RequestRecord.tags` and the `recordUsage` payload's `tags`.
115
122
 
116
123
  ## Development
117
124
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mupt-ai/dari-gateway",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
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": {
@@ -10,6 +10,7 @@
10
10
  "type": "module",
11
11
  "exports": {
12
12
  ".": "./src/index.ts",
13
+ "./bun": "./src/bun.ts",
13
14
  "./*": "./src/*.ts"
14
15
  },
15
16
  "bin": {
@@ -19,6 +20,7 @@
19
20
  "src",
20
21
  "!src/**/*.test.ts",
21
22
  "!src/**/*.fixtures.ts",
23
+ "!src/**/*.cases.ts",
22
24
  "!src/test_support",
23
25
  "!src/**/test_support.ts",
24
26
  "dari-gateway.example.yaml",
package/src/bun.ts ADDED
@@ -0,0 +1,4 @@
1
+ // Bun-only entry points: the Bun.serve HTTP server and the self-hosted
2
+ // gateway, whose state lives in bun:sqlite.
3
+ export { installShutdownHandlers, startServer } from "./http/serve";
4
+ export { startSelfHostedGateway } from "./self_host/main";
@@ -122,7 +122,7 @@ export async function buildRoutingContext(args: {
122
122
  imageInput: args.credentials.selector_image_input_enabled === true,
123
123
  });
124
124
  const leaseDepth = interactionLeaseFenceDepth(args.interaction);
125
- const metadataLookup = await modelMetadataLookup(args.credentials.model_configs);
125
+ const metadataLookup = modelMetadataLookup(args.credentials.model_configs);
126
126
  const targetsByModel = new Map<string, ProviderIdentity>();
127
127
  for (const model of configuredCandidateModels) {
128
128
  targetsByModel.set(
package/src/index.ts CHANGED
@@ -1,21 +1,22 @@
1
1
  // Public entry points. Every module is also importable by path
2
2
  // (`@mupt-ai/dari-gateway/<dir>/<module>`) for deployments that compose the
3
- // gateway with their own platform.
3
+ // gateway with their own platform. This entry needs no Bun APIs; the Bun
4
+ // server and the SQLite-backed self-hosted gateway live in `./bun`.
4
5
  export { createRequestHandler, type GatewayHandlerOptions } from "./http/handler";
5
- export { installShutdownHandlers, startServer } from "./http/serve";
6
6
  export type {
7
7
  GatewayPlatform,
8
8
  OperationalErrorContext,
9
9
  PendingLeaseWrite,
10
10
  PrefixStateWrite,
11
+ ProviderCredentialRequest,
11
12
  RequestErrorContext,
12
13
  RequestRecord,
13
14
  RequestSummary,
15
+ RequestTags,
14
16
  ResolveRouterRequest,
15
17
  SubscriptionHold,
16
18
  } from "./platform/platform";
17
19
  export { createAutoRoutingSelectorBackend } from "./strategies/auto_routing_selector";
18
20
  export { createPrioritySelectorBackend } from "./strategies/priority_selector";
19
21
  export { createLlmSelectorBackend, type SelectorBackend } from "./strategies/routing";
20
- export type { ResolvedRouter } from "./types";
21
- export { startSelfHostedGateway } from "./self_host/main";
22
+ export type { ProviderCredential, ResolvedRouter } from "./types";
@@ -33,7 +33,7 @@ export async function handleModels(request: Request, routerId: string): Promise<
33
33
  },
34
34
  });
35
35
  }
36
- const metadataLookup = await modelMetadataLookup(router.model_configs);
36
+ const metadataLookup = modelMetadataLookup(router.model_configs);
37
37
  const imageInputFor = (model: string) => modelSupportsImageInput(metadataLookup(model));
38
38
  return Response.json(codexModelsResponse(router, imageInputFor), {
39
39
  headers: {
@@ -264,6 +264,9 @@ export type InteractionUsage = {
264
264
  cacheWrite: number;
265
265
  total: number;
266
266
  };
267
+ // "provider" when cost.total is the provider's billed amount rather than a
268
+ // price from the model's rates.
269
+ costSource?: "provider";
267
270
  };
268
271
 
269
272
  export type InteractionOutput = {
@@ -6,7 +6,12 @@ import type {
6
6
  import type { HarnessConversationIdentity } from "../runtime/harness_identity";
7
7
  import type { InteractionRequest, SourceProtocol } from "../interaction/types";
8
8
  import type { SubscriptionUsageLimit } from "../providers/subscription_limits";
9
- import type { ClientHarness, ProviderKeySource, ResolvedRouter } from "../types";
9
+ import type {
10
+ ClientHarness,
11
+ ProviderCredential,
12
+ ProviderKeySource,
13
+ ResolvedRouter,
14
+ } from "../types";
10
15
 
11
16
  /**
12
17
  * Everything the gateway runtime needs from the deployment around it: router
@@ -24,6 +29,15 @@ export type GatewayPlatform = {
24
29
  * `prefixHashes`. Throws HttpError(401) for an unknown key.
25
30
  */
26
31
  resolveRouter(request: ResolveRouterRequest): Promise<ResolvedRouter>;
32
+ /**
33
+ * Optional. Supply the credential for one enabled model of this request,
34
+ * consulted after `resolveRouter` and before its provider keys (for the
35
+ * self-hosted gateway, its environment keys), so an embedder can serve
36
+ * per-tenant keys from its own store. Undefined keeps the resolved key.
37
+ */
38
+ resolveProviderCredential?(
39
+ request: ProviderCredentialRequest,
40
+ ): Promise<ProviderCredential | undefined>;
27
41
  /** Persist one completed turn's prefix entry and/or refresh a read entry. */
28
42
  writePrefixState(write: PrefixStateWrite): Promise<void>;
29
43
  /** Set or clear a conversation's prefetched routing lease. */
@@ -53,6 +67,15 @@ export type ResolveRouterRequest = {
53
67
  clientHarness?: ClientHarness;
54
68
  };
55
69
 
70
+ export type ProviderCredentialRequest = {
71
+ provider: Provider;
72
+ model: string;
73
+ routerId: string;
74
+ organizationId: string;
75
+ // The gateway key the request authenticated with.
76
+ apiKey: string;
77
+ };
78
+
56
79
  export type PrefixStateWrite = {
57
80
  organization_id: string;
58
81
  router_id: string;
@@ -94,8 +117,14 @@ export type RequestRecord = {
94
117
  isNewConversation: boolean;
95
118
  zdr: boolean;
96
119
  harnessConversationIdentity?: HarnessConversationIdentity;
120
+ // Parsed from the request's X-Dari-Tags header; empty when absent.
121
+ tags: RequestTags;
97
122
  };
98
123
 
124
+ // Caller-supplied attribution labels (run, trial, setting), opaque to the
125
+ // gateway.
126
+ export type RequestTags = Record<string, string>;
127
+
99
128
  export type RequestSummary = {
100
129
  request_id: string;
101
130
  organization_id: string;
@@ -130,7 +159,10 @@ export type RequestSummary = {
130
159
  cache_write_tokens: number | null;
131
160
  output_tokens: number | null;
132
161
  total_tokens: number | null;
162
+ // Provider-reported cost when the provider returns one (OpenRouter, Vercel
163
+ // AI Gateway), otherwise priced from the model's rates when known.
133
164
  total_cost_usd: number | null;
165
+ cost_source: "provider" | "pricing" | null;
134
166
  pricing_known: boolean | null;
135
167
  };
136
168
 
@@ -1,3 +1,22 @@
1
+ import {
2
+ getSupportedThinkingLevels,
3
+ normalizeContext,
4
+ type Api,
5
+ type AssistantMessageEventStream,
6
+ type Context,
7
+ type Model,
8
+ type ProviderStreams,
9
+ } from "@mupt-ai/pi-ai";
10
+ import { anthropicMessagesApi } from "@mupt-ai/pi-ai/api/anthropic-messages.lazy";
11
+ import { azureOpenAIResponsesApi } from "@mupt-ai/pi-ai/api/azure-openai-responses.lazy";
12
+ import { bedrockConverseStreamApi } from "@mupt-ai/pi-ai/api/bedrock-converse-stream.lazy";
13
+ import { googleGenerativeAIApi } from "@mupt-ai/pi-ai/api/google-generative-ai.lazy";
14
+ import { googleVertexApi } from "@mupt-ai/pi-ai/api/google-vertex.lazy";
15
+ import { mistralConversationsApi } from "@mupt-ai/pi-ai/api/mistral-conversations.lazy";
16
+ import { openAICodexResponsesApi } from "@mupt-ai/pi-ai/api/openai-codex-responses.lazy";
17
+ import { openAICompletionsApi } from "@mupt-ai/pi-ai/api/openai-completions.lazy";
18
+ import { openAIResponsesApi } from "@mupt-ai/pi-ai/api/openai-responses.lazy";
19
+ import { getBuiltinModel } from "@mupt-ai/pi-ai/providers/all";
1
20
  import {
2
21
  REASONING_EFFORTS,
3
22
  isRecord,
@@ -22,6 +41,12 @@ import {
22
41
  messageWithSafeguardResults,
23
42
  safeguardResultsTap,
24
43
  } from "./anthropic_safeguards";
44
+ import {
45
+ eventsWithProviderReportedCost,
46
+ messageWithProviderReportedCost,
47
+ providerReportedCostTap,
48
+ reportsProviderCost,
49
+ } from "./provider_reported_cost";
25
50
  import {
26
51
  eventsWithSubscriptionCacheWrites,
27
52
  messageWithSubscriptionCacheWrites,
@@ -43,6 +68,12 @@ export type ModelRuntimeMetadata = {
43
68
  supportedThinkingLevels: string[];
44
69
  };
45
70
 
71
+ // A platform-supplied endpoint for one call (see ProviderCredential).
72
+ export type ProviderEndpoint = {
73
+ baseUrl?: string;
74
+ headers?: Record<string, string>;
75
+ };
76
+
46
77
  export type ProviderCompletion = {
47
78
  message: Record<string, unknown>;
48
79
  source: ProviderIdentity;
@@ -55,14 +86,40 @@ export type ProviderEventStream = {
55
86
 
56
87
  export type ModelMetadataLookup = (modelId: string, pricing?: ModelPricing | null) => ModelRuntimeMetadata;
57
88
 
58
- let piAiPromise: Promise<PiAi> | null = null;
89
+ // Pi's built-in providers stream through these API implementations unchanged.
90
+ // Dari always passes resolved credentials, so it dispatches on the API rather
91
+ // than through a Models collection with its own auth resolution.
92
+ const PI_APIS: Record<string, ProviderStreams> = {
93
+ "anthropic-messages": anthropicMessagesApi(),
94
+ "azure-openai-responses": azureOpenAIResponsesApi(),
95
+ "bedrock-converse-stream": bedrockConverseStreamApi(),
96
+ "google-generative-ai": googleGenerativeAIApi(),
97
+ "google-vertex": googleVertexApi(),
98
+ "mistral-conversations": mistralConversationsApi(),
99
+ "openai-codex-responses": openAICodexResponsesApi(),
100
+ "openai-completions": openAICompletionsApi(),
101
+ "openai-responses": openAIResponsesApi(),
102
+ };
59
103
 
60
- async function piAi(): Promise<PiAi> {
61
- // Pi 0.80 keeps the legacy global dispatch and catalog surface under /compat.
62
- piAiPromise ??= import("@mupt-ai/pi-ai/compat") as unknown as Promise<PiAi>;
63
- return piAiPromise;
104
+ function piStreamSimple(
105
+ model: Record<string, unknown>,
106
+ context: Record<string, unknown>,
107
+ options: Record<string, unknown>,
108
+ ): AssistantMessageEventStream {
109
+ const api = PI_APIS[String(model.api)];
110
+ if (!api) throw new Error(`No Pi API implementation for ${String(model.api)}.`);
111
+ return api.streamSimple(model as unknown as Model<Api>, normalizeContext(context as unknown as Context), options);
64
112
  }
65
113
 
114
+ export const pi: PiAi = {
115
+ completeSimple: async (model, context, options) =>
116
+ await piStreamSimple(model, context, options).result() as unknown as Record<string, unknown>,
117
+ streamSimple: (model, context, options) =>
118
+ piStreamSimple(model, context, options) as unknown as AsyncIterable<Record<string, unknown>>,
119
+ getModel: (provider, id) => getBuiltinModel(provider as never, id as never) as unknown as Record<string, unknown> | undefined,
120
+ getSupportedThinkingLevels: (model) => getSupportedThinkingLevels(model as unknown as Model<Api>),
121
+ };
122
+
66
123
  export async function completeInteractionModel(args: {
67
124
  model: string;
68
125
  request: InteractionRequest;
@@ -70,18 +127,19 @@ export async function completeInteractionModel(args: {
70
127
  auth?: ResolvedProviderAuth;
71
128
  pricing?: ModelPricing | null;
72
129
  config?: ResolvedRouterModelConfig;
130
+ endpoint?: ProviderEndpoint;
73
131
  sessionId?: string;
74
132
  selectedReasoningEffort?: ReasoningEffort;
75
133
  timeoutMs?: number;
76
134
  signal?: AbortSignal;
77
135
  fetch?: ProviderFetch;
78
136
  }): Promise<ProviderCompletion> {
79
- const pi = await piAi();
80
- const model = buildModel(pi, args.model, args.pricing, args.config);
137
+ const model = modelAtEndpoint(buildModel(pi, args.model, args.pricing, args.config), args.endpoint);
81
138
  const source = providerIdentityFromModel(model);
82
139
  const context = buildContext(args.request, source);
140
+ const costTap = reportsProviderCost(model) ? providerReportedCostTap(args.fetch) : undefined;
83
141
  const safeguards = forwardsAnthropicSafeguards(args.request, model)
84
- ? safeguardResultsTap(args.fetch)
142
+ ? safeguardResultsTap(costTap?.fetch ?? args.fetch)
85
143
  : undefined;
86
144
  const options = buildOptions(
87
145
  args.request,
@@ -93,16 +151,19 @@ export async function completeInteractionModel(args: {
93
151
  args.selectedReasoningEffort,
94
152
  args.timeoutMs,
95
153
  args.signal,
96
- safeguards?.fetch ?? args.fetch,
154
+ safeguards?.fetch ?? costTap?.fetch ?? args.fetch,
97
155
  );
98
156
  const usageContext = subscriptionUsageContext(args.model, model, source);
99
157
  const message = options.emitProviderEvents === true
100
158
  ? await completeProviderEventStream(pi.streamSimple(model, context, options))
101
159
  : await pi.completeSimple(model, context, options);
102
160
  return {
103
- message: messageWithSubscriptionCacheWrites(
104
- messageWithSafeguardResults(message, safeguards?.results()),
105
- usageContext
161
+ message: messageWithProviderReportedCost(
162
+ messageWithSubscriptionCacheWrites(
163
+ messageWithSafeguardResults(message, safeguards?.results()),
164
+ usageContext
165
+ ),
166
+ costTap?.cost(),
106
167
  ),
107
168
  source,
108
169
  };
@@ -167,16 +228,17 @@ export async function streamInteractionModel(args: {
167
228
  auth?: ResolvedProviderAuth;
168
229
  pricing?: ModelPricing | null;
169
230
  config?: ResolvedRouterModelConfig;
231
+ endpoint?: ProviderEndpoint;
170
232
  sessionId?: string;
171
233
  selectedReasoningEffort?: ReasoningEffort;
172
234
  signal?: AbortSignal;
173
235
  fetch?: ProviderFetch;
174
236
  }): Promise<ProviderEventStream> {
175
- const pi = await piAi();
176
- const model = buildModel(pi, args.model, args.pricing, args.config);
237
+ const model = modelAtEndpoint(buildModel(pi, args.model, args.pricing, args.config), args.endpoint);
177
238
  const source = providerIdentityFromModel(model);
239
+ const costTap = reportsProviderCost(model) ? providerReportedCostTap(args.fetch) : undefined;
178
240
  const safeguards = forwardsAnthropicSafeguards(args.request, model)
179
- ? safeguardResultsTap(args.fetch)
241
+ ? safeguardResultsTap(costTap?.fetch ?? args.fetch)
180
242
  : undefined;
181
243
  const events = pi.streamSimple(
182
244
  model,
@@ -191,20 +253,34 @@ export async function streamInteractionModel(args: {
191
253
  args.selectedReasoningEffort,
192
254
  undefined,
193
255
  args.signal,
194
- safeguards?.fetch ?? args.fetch,
256
+ safeguards?.fetch ?? costTap?.fetch ?? args.fetch,
195
257
  )
196
258
  );
259
+ const withCacheWrites = eventsWithSubscriptionCacheWrites(
260
+ safeguards ? eventsWithSafeguardResults(events, safeguards) : events,
261
+ subscriptionUsageContext(args.model, model, source)
262
+ );
197
263
  return {
198
- events: eventsWithSubscriptionCacheWrites(
199
- safeguards ? eventsWithSafeguardResults(events, safeguards) : events,
200
- subscriptionUsageContext(args.model, model, source)
201
- ),
264
+ events: costTap ? eventsWithProviderReportedCost(withCacheWrites, costTap) : withCacheWrites,
202
265
  source,
203
266
  };
204
267
  }
205
268
 
206
- export async function modelContextWindow(modelId: string): Promise<number | null> {
207
- const pi = await piAi();
269
+ function modelAtEndpoint(
270
+ model: Record<string, unknown>,
271
+ endpoint: ProviderEndpoint | undefined,
272
+ ): Record<string, unknown> {
273
+ if (endpoint === undefined) return model;
274
+ return {
275
+ ...model,
276
+ ...(endpoint.baseUrl ? { baseUrl: endpoint.baseUrl } : {}),
277
+ ...(endpoint.headers
278
+ ? { headers: { ...(isRecord(model.headers) ? model.headers : {}), ...endpoint.headers } }
279
+ : {}),
280
+ };
281
+ }
282
+
283
+ export function modelContextWindow(modelId: string): number | null {
208
284
  const provider = providerForModel(modelId);
209
285
  const nativeId = nativeModelId(modelId);
210
286
  const registered = resolveModelMetadata(pi, provider, modelId, nativeId);
@@ -215,11 +291,10 @@ export async function modelContextWindow(modelId: string): Promise<number | null
215
291
  * Pi's catalog price for a canonical model served by `provider`, in USD per
216
292
  * million tokens. Null when Pi does not list the model.
217
293
  */
218
- export async function registeredModelPrice(
294
+ export function registeredModelPrice(
219
295
  modelId: string,
220
296
  provider: string = providerForModel(modelId),
221
- ): Promise<RouterModelPrice | null> {
222
- const pi = await piAi();
297
+ ): RouterModelPrice | null {
223
298
  const registered = resolveModelMetadata(pi, provider, modelId, nativeModelId(modelId));
224
299
  const cost = registered?.cost;
225
300
  if (!isRecord(cost) || typeof cost.input !== "number" || typeof cost.output !== "number") {
@@ -233,10 +308,9 @@ export async function registeredModelPrice(
233
308
  };
234
309
  }
235
310
 
236
- export async function modelMetadataLookup(
311
+ export function modelMetadataLookup(
237
312
  configs?: Record<string, ResolvedRouterModelConfig>
238
- ): Promise<ModelMetadataLookup> {
239
- const pi = await piAi();
313
+ ): ModelMetadataLookup {
240
314
  return (modelId: string, pricing?: ModelPricing | null) => {
241
315
  const model = buildModel(pi, modelId, pricing, configs?.[modelId]);
242
316
  return {