@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.
@@ -465,6 +465,7 @@ function interactionUsage(value: unknown, source: ProviderIdentity): Interaction
465
465
  ? calculatedTotalCost
466
466
  : finiteNumber(cost.total) ?? calculatedTotalCost,
467
467
  },
468
+ ...(usage.costSource === "provider" ? { costSource: "provider" as const } : {}),
468
469
  };
469
470
  }
470
471
 
@@ -10,6 +10,7 @@ import {
10
10
 
11
11
  import { executionProviderForModel, keySourceForModel } from "../models/execution_provider";
12
12
  import { HttpError } from "../http/errors";
13
+ import type { RequestTags } from "../platform/platform";
13
14
  import type { InteractionRequest, ProviderIdentity } from "../interaction/types";
14
15
  import {
15
16
  CustomerDisconnectedError,
@@ -43,6 +44,7 @@ import {
43
44
  import {
44
45
  completeInteractionModel,
45
46
  streamInteractionModel,
47
+ type ProviderEndpoint,
46
48
  type ProviderEventStream,
47
49
  } from "./pi";
48
50
  import { promptTokenEstimateFor, type PromptTokenEstimate } from "./cancel_estimate";
@@ -57,6 +59,8 @@ type ProviderCallConfig = {
57
59
  sessionId: string;
58
60
  reasoningEffort: ReasoningEffort;
59
61
  promptEstimate: PromptTokenEstimate | undefined;
62
+ // A platform-supplied endpoint replacing the model's configured one.
63
+ endpoint?: ProviderEndpoint;
60
64
  };
61
65
 
62
66
  export type ProviderExecution = ProviderCallConfig & {
@@ -101,6 +105,7 @@ export function prepareProviderExecution(args: {
101
105
  conversationId: string;
102
106
  promptCacheKey?: string;
103
107
  costEstimates?: readonly CandidateCostEstimate[];
108
+ tags?: RequestTags;
104
109
  }): ProviderExecution {
105
110
  const model = args.decision.selectedModel;
106
111
  const promptEstimate = promptTokenEstimateFor(args.costEstimates, model, args.decision.reasoningEffort);
@@ -109,11 +114,19 @@ export function prepareProviderExecution(args: {
109
114
  throw new HttpError(400, "The prompt exceeds this model's standard-price context range. Shorten or compact the conversation.", "context_length_exceeded");
110
115
  }
111
116
  const provider = executionProviderForModel(args.credentials, model);
112
- const providerKeySource = keySourceForModel(args.credentials, model);
113
- const auth = providerAuthForSelectedModel({
114
- selectedModel: model,
115
- credentials: args.credentials,
116
- });
117
+ const supplied = args.credentials.provider_credentials?.[model];
118
+ const providerKeySource = supplied ? "user" : keySourceForModel(args.credentials, model);
119
+ if (args.credentials.strict === true && providerKeySource === "subscription") {
120
+ throw new HttpError(
121
+ 400,
122
+ `Strict routers do not serve '${model}' from a personal subscription.`,
123
+ "strict_subscription_refused",
124
+ "model",
125
+ );
126
+ }
127
+ const auth: ProviderAuth = supplied
128
+ ? { type: "bearer", apiKey: supplied.apiKey.trim() }
129
+ : providerAuthForSelectedModel({ selectedModel: model, credentials: args.credentials });
117
130
  return {
118
131
  model,
119
132
  apiKey: auth.type === "bearer" ? auth.apiKey : "",
@@ -130,6 +143,9 @@ export function prepareProviderExecution(args: {
130
143
  : args.conversationId,
131
144
  reasoningEffort: args.decision.reasoningEffort,
132
145
  promptEstimate,
146
+ ...(supplied?.baseUrl || supplied?.headers
147
+ ? { endpoint: { baseUrl: supplied.baseUrl, headers: supplied.headers } }
148
+ : {}),
133
149
  provider,
134
150
  providerKeySource,
135
151
  ...(providerKeySource === "subscription" && args.credentials.attributed_user_id
@@ -144,6 +160,7 @@ export function prepareProviderExecution(args: {
144
160
  routerId: args.routerId,
145
161
  selectedModel: model,
146
162
  keySource: providerKeySource,
163
+ tags: args.tags ?? {},
147
164
  }),
148
165
  headers: {
149
166
  "X-Dari-Selected-Model": model,
@@ -212,6 +229,7 @@ export async function startProviderStream(args: {
212
229
  config: args.execution.config,
213
230
  sessionId: args.execution.sessionId,
214
231
  selectedReasoningEffort: args.execution.reasoningEffort,
232
+ endpoint: args.execution.endpoint,
215
233
  signal: customerAbort.signal,
216
234
  fetch: responseCapture?.fetch,
217
235
  });
@@ -306,6 +324,7 @@ async function completeProvider(args: {
306
324
  config: args.execution.config,
307
325
  sessionId: args.execution.sessionId,
308
326
  selectedReasoningEffort: args.execution.reasoningEffort,
327
+ endpoint: args.execution.endpoint,
309
328
  signal: args.signal,
310
329
  fetch: args.fetch,
311
330
  });
@@ -0,0 +1,138 @@
1
+ import { isRecord } from "@mupt-ai/dari-router/policy-engine";
2
+
3
+ import { gatewayFor } from "../models/gateways";
4
+ import { providerFailureFromCaught } from "../runtime/provider_stream_outcome";
5
+ import type { ProviderFetch } from "./response_capture";
6
+
7
+ // Gateways bill per call and say what they billed: OpenRouter as `usage.cost`
8
+ // on the final chunk, Vercel AI Gateway as `providerMetadata.gateway.cost`.
9
+ // Pi prices usage from model rates and drops both, so the amount is read off
10
+ // the raw response bytes and replaces the priced total.
11
+
12
+ export function reportsProviderCost(model: Record<string, unknown>): boolean {
13
+ return gatewayFor(model.provider) !== undefined;
14
+ }
15
+
16
+ export type ProviderReportedCostTap = {
17
+ fetch: ProviderFetch;
18
+ cost: () => number | undefined;
19
+ };
20
+
21
+ export function providerReportedCostTap(
22
+ fetch: ProviderFetch = globalThis.fetch,
23
+ ): ProviderReportedCostTap {
24
+ let cost: number | undefined;
25
+ return {
26
+ cost: () => cost,
27
+ fetch: async (input, init) => {
28
+ const response = await fetch(input, init);
29
+ if (!response.ok || response.body === null) return response;
30
+ cost = undefined;
31
+ return new Response(
32
+ response.body.pipeThrough(costScanner((value) => {
33
+ cost = value;
34
+ })),
35
+ { status: response.status, statusText: response.statusText, headers: response.headers },
36
+ );
37
+ },
38
+ };
39
+ }
40
+
41
+ export function messageWithProviderReportedCost(
42
+ message: Record<string, unknown>,
43
+ cost: number | undefined,
44
+ ): Record<string, unknown> {
45
+ if (cost === undefined) return message;
46
+ // A thrown error (or other record without a stop reason) becomes a failure
47
+ // message first: spreading an Error drops its non-enumerable `message`, and
48
+ // failure handling keeps usage only on messages that stopped in error.
49
+ const base = message.stopReason === undefined
50
+ ? { ...providerFailureFromCaught(message), ...(isRecord(message.usage) ? { usage: message.usage } : {}) }
51
+ : message;
52
+ const usage = isRecord(base.usage) ? base.usage : {};
53
+ return {
54
+ ...base,
55
+ usage: {
56
+ ...usage,
57
+ cost: { ...(isRecord(usage.cost) ? usage.cost : {}), total: cost },
58
+ costSource: "provider",
59
+ },
60
+ };
61
+ }
62
+
63
+ // Aborted and failed calls end in an error event carrying the partial
64
+ // message; it keeps whatever cost the provider reported before the end.
65
+ export async function* eventsWithProviderReportedCost(
66
+ events: AsyncIterable<Record<string, unknown>>,
67
+ tap: ProviderReportedCostTap,
68
+ ): AsyncIterable<Record<string, unknown>> {
69
+ for await (const event of events) {
70
+ if (event.type === "done" && isRecord(event.message)) {
71
+ yield { ...event, message: messageWithProviderReportedCost(event.message, tap.cost()) };
72
+ continue;
73
+ }
74
+ if (event.type === "error" && isRecord(event.error)) {
75
+ yield { ...event, error: messageWithProviderReportedCost(event.error, tap.cost()) };
76
+ continue;
77
+ }
78
+ yield event;
79
+ }
80
+ }
81
+
82
+ export function reportedCost(body: unknown): number | undefined {
83
+ if (!isRecord(body)) return undefined;
84
+ const gateway = isRecord(body.providerMetadata) && isRecord(body.providerMetadata.gateway)
85
+ ? body.providerMetadata.gateway
86
+ : undefined;
87
+ return usd(isRecord(body.usage) ? body.usage.cost : undefined) ?? usd(gateway?.cost);
88
+ }
89
+
90
+ function usd(value: unknown): number | undefined {
91
+ const amount = typeof value === "string" && value.trim() !== "" ? Number(value) : value;
92
+ return typeof amount === "number" && Number.isFinite(amount) && amount >= 0 ? amount : undefined;
93
+ }
94
+
95
+ // Reads SSE `data:` lines and plain JSON bodies alike; the last reported
96
+ // amount wins.
97
+ function costScanner(onCost: (cost: number) => void): TransformStream<Uint8Array, Uint8Array> {
98
+ const decoder = new TextDecoder();
99
+ let pending = "";
100
+ let body = "";
101
+ let sse = false;
102
+ const scan = (line: string) => {
103
+ if (!line.startsWith("data:") || !line.includes("cost")) return;
104
+ try {
105
+ const cost = reportedCost(JSON.parse(line.slice("data:".length)));
106
+ if (cost !== undefined) onCost(cost);
107
+ } catch {
108
+ // Not JSON: [DONE] or a keep-alive.
109
+ }
110
+ };
111
+ return new TransformStream({
112
+ transform(chunk, controller) {
113
+ controller.enqueue(chunk);
114
+ const text = decoder.decode(chunk, { stream: true });
115
+ pending += text;
116
+ const lines = pending.split("\n");
117
+ pending = lines.pop() ?? "";
118
+ for (const line of lines) {
119
+ const trimmed = line.trimEnd();
120
+ if (trimmed.startsWith("data:")) sse = true;
121
+ scan(trimmed);
122
+ }
123
+ if (!sse && body.length < 1024 * 1024) body += text;
124
+ },
125
+ flush() {
126
+ const tail = decoder.decode();
127
+ scan((pending + tail).trimEnd());
128
+ body += tail;
129
+ if (sse || !body.includes("cost")) return;
130
+ try {
131
+ const cost = reportedCost(JSON.parse(body));
132
+ if (cost !== undefined) onCost(cost);
133
+ } catch {
134
+ // Not a JSON body.
135
+ }
136
+ },
137
+ });
138
+ }
@@ -138,7 +138,7 @@ async function reportTerminalOnce(
138
138
  ? args.reportBarrier
139
139
  : Promise.race([
140
140
  args.reportBarrier,
141
- Bun.sleep(args.cancelBarrierCapMs ?? CANCEL_REPORT_BARRIER_CAP_MS),
141
+ new Promise((resolve) => setTimeout(resolve, args.cancelBarrierCapMs ?? CANCEL_REPORT_BARRIER_CAP_MS)),
142
142
  ]));
143
143
  } catch (error) {
144
144
  capture(error, "routing_report_barrier_failed");
@@ -57,3 +57,30 @@ function pickCandidate(
57
57
  }
58
58
  return candidates[0]!;
59
59
  }
60
+
61
+ /* A strict router serves only the pinned `dari/model/<id>` at exactly the
62
+ requested thinking level (the model's default when none is sent), and
63
+ refuses everything else instead of substituting. */
64
+ export function strictModelDecision(prepared: PreparedRoutedCompletion): RoutedDecision {
65
+ const modelId = explicitModelId(prepared.interaction.model);
66
+ if (modelId === null) {
67
+ throw new HttpError(
68
+ 400,
69
+ "This router is strict: request a pinned model as 'dari/model/<id>'.",
70
+ "strict_model_required",
71
+ "model",
72
+ );
73
+ }
74
+ const { decision } = explicitModelDecision(prepared)!;
75
+ const requested = prepared.interaction.reasoning?.effort
76
+ ?? prepared.credentials.model_configs?.[modelId]?.default_thinking_level;
77
+ if (requested !== undefined && requested !== null && decision.reasoningEffort !== requested) {
78
+ throw new HttpError(
79
+ 400,
80
+ `Model '${modelId}' cannot serve thinking level '${requested}' on this router.`,
81
+ "thinking_level_unavailable",
82
+ "reasoning_effort",
83
+ );
84
+ }
85
+ return { decision };
86
+ }
@@ -8,10 +8,13 @@ import {
8
8
  type RoutingContext,
9
9
  } from "../context/routing_context";
10
10
  import { isBackgroundRequest, type InteractionRequest } from "../interaction/types";
11
+ import { executionProviderForModel } from "../models/execution_provider";
12
+ import { gatewayPlatform, type RequestTags } from "../platform/platform";
11
13
  import { resolveRouter } from "../strategies/routing";
12
14
  import { RequestReporter, type ReportedCandidatePruning } from "../telemetry/report";
13
15
  import { RoutingBillingFinalization } from "../telemetry/billing_finalization";
14
- import type { ClientHarness, ResolvedRouter } from "../types";
16
+ import type { ClientHarness, ProviderCredential, ResolvedRouter } from "../types";
17
+ import { requestTags } from "./request_tags";
15
18
  import type { RoutedRuntimeReporter } from "./runtime_reporter";
16
19
  import type { RoutedProtocol } from "./routed_protocol";
17
20
  import {
@@ -28,6 +31,7 @@ export type PreparedRoutedCompletion = {
28
31
  protocol: RoutedProtocol;
29
32
  credentials: ResolvedRouter;
30
33
  billable: boolean;
34
+ tags: RequestTags;
31
35
  harnessConversationIdentity?: HarnessConversationIdentity;
32
36
  routing: RoutingContext;
33
37
  reporter: RoutedRuntimeReporter;
@@ -42,9 +46,11 @@ export async function prepareRoutedCompletion(args: {
42
46
  startedMs: number;
43
47
  requestId: string;
44
48
  clientRequestId: string | null;
49
+ tagsHeader: string | null;
45
50
  clientHarness?: ClientHarness;
46
51
  harnessConversationIdentity?: HarnessConversationIdentity;
47
52
  }): Promise<PreparedRoutedCompletion> {
53
+ const tags = requestTags(args.tagsHeader);
48
54
  // Eval relevance draws from the public catalog, which resolve only returns
49
55
  // on request.
50
56
  const resolveRequest = {
@@ -119,6 +125,7 @@ export async function prepareRoutedCompletion(args: {
119
125
  }
120
126
  credentials = conversationCredentials;
121
127
  }
128
+ credentials = await withProviderCredentials(credentials, args.apiKey);
122
129
  const billable = true;
123
130
  const routing = await buildRoutingContext({
124
131
  routerId: prefixFingerprintRouterId,
@@ -131,6 +138,7 @@ export async function prepareRoutedCompletion(args: {
131
138
  const reporter = new RequestReporter({
132
139
  requestId: args.requestId,
133
140
  clientRequestId: args.clientRequestId,
141
+ tags,
134
142
  credentials,
135
143
  routerId: canonicalRouterId,
136
144
  interaction: args.interaction,
@@ -168,12 +176,40 @@ export async function prepareRoutedCompletion(args: {
168
176
  protocol: args.protocol,
169
177
  credentials,
170
178
  billable,
179
+ tags,
171
180
  harnessConversationIdentity: args.harnessConversationIdentity,
172
181
  routing,
173
182
  reporter,
174
183
  };
175
184
  }
176
185
 
186
+ // Ask the platform for each enabled model's credential up front, so provider
187
+ // execution and fallback stay synchronous.
188
+ async function withProviderCredentials(
189
+ credentials: ResolvedRouter,
190
+ apiKey: string,
191
+ ): Promise<ResolvedRouter> {
192
+ const platform = gatewayPlatform();
193
+ if (platform.resolveProviderCredential === undefined) return credentials;
194
+ const resolved = await Promise.all(
195
+ credentials.enabled_models.map(async (model) => [
196
+ model,
197
+ await platform.resolveProviderCredential!({
198
+ provider: executionProviderForModel(credentials, model),
199
+ model,
200
+ routerId: credentials.router_id,
201
+ organizationId: credentials.organization_id,
202
+ apiKey,
203
+ }),
204
+ ] as const),
205
+ );
206
+ const provider_credentials: Record<string, ProviderCredential> = {};
207
+ for (const [model, credential] of resolved) {
208
+ if (credential?.apiKey.trim()) provider_credentials[model] = credential;
209
+ }
210
+ return { ...credentials, provider_credentials };
211
+ }
212
+
177
213
  // Core's cache-aware pruning audit, extended with the context layer's image
178
214
  // capability exclusions. The exclusions survive on their own when custom
179
215
  // routing produced no core audit (missing config), so the error path keeps
@@ -0,0 +1,34 @@
1
+ import { HttpError } from "../http/errors";
2
+ import type { RequestTags } from "../platform/platform";
3
+
4
+ const TAGS_MAX_CHARS = 2048;
5
+ const TAGS_MAX_COUNT = 32;
6
+ const TAG_KEY = /^[A-Za-z0-9_.:-]{1,64}$/;
7
+ const TAG_VALUE_MAX_CHARS = 256;
8
+
9
+ /**
10
+ * Parse `X-Dari-Tags: run=r1,trial=3,setting=low` into attribution labels for
11
+ * the platform's request and usage records. Malformed or oversized headers
12
+ * are refused rather than silently dropped, so cost is never misattributed.
13
+ */
14
+ export function requestTags(header: string | null): RequestTags {
15
+ const tags: RequestTags = {};
16
+ if (header === null || header.trim() === "") return tags;
17
+ if (header.length > TAGS_MAX_CHARS) throw invalidTags(`X-Dari-Tags exceeds ${TAGS_MAX_CHARS} characters.`);
18
+ const pairs = header.split(",");
19
+ if (pairs.length > TAGS_MAX_COUNT) throw invalidTags(`X-Dari-Tags has more than ${TAGS_MAX_COUNT} tags.`);
20
+ for (const pair of pairs) {
21
+ const separator = pair.indexOf("=");
22
+ const key = pair.slice(0, separator).trim();
23
+ const value = pair.slice(separator + 1).trim();
24
+ if (separator < 0 || !TAG_KEY.test(key) || value.length > TAG_VALUE_MAX_CHARS) {
25
+ throw invalidTags("X-Dari-Tags must be comma-separated key=value pairs.");
26
+ }
27
+ tags[key] = value;
28
+ }
29
+ return tags;
30
+ }
31
+
32
+ function invalidTags(message: string): HttpError {
33
+ return new HttpError(400, message, "invalid_tags");
34
+ }
@@ -37,6 +37,7 @@ export async function handleRoutedCompletion(args: {
37
37
  startedMs,
38
38
  requestId,
39
39
  clientRequestId,
40
+ tagsHeader: args.request.headers.get("x-dari-tags"),
40
41
  clientHarness: args.clientHarness,
41
42
  harnessConversationIdentity: args.harnessConversationIdentity,
42
43
  }).catch((error: unknown) => ({ error }));
@@ -56,6 +57,7 @@ export async function handleRoutedCompletion(args: {
56
57
  // Only auto-selection needs the plan: an explicitly requested model and
57
58
  // background work are served without the selector.
58
59
  const selects =
60
+ prepared.credentials.strict !== true &&
59
61
  explicitModelId(prepared.interaction.model) === null &&
60
62
  !isBackgroundRequest(prepared.interaction) &&
61
63
  routerSelectsModels(prepared.credentials);
@@ -36,7 +36,7 @@ import { scrubSecrets } from "../telemetry/scrub";
36
36
  import type { ResolvedRouter } from "../types";
37
37
  import type { PreparedRoutedCompletion } from "./prepare_routed_completion";
38
38
  import { auxiliaryDecision } from "./claude_code/auxiliary_routing.js";
39
- import { explicitModelDecision } from "./explicit_model.js";
39
+ import { explicitModelDecision, strictModelDecision } from "./explicit_model.js";
40
40
  import {
41
41
  clearPendingLease,
42
42
  committedLeaseDecision,
@@ -238,6 +238,7 @@ async function prepareExplicitRoutedCompletion(
238
238
  protocol: inProcessProtocol,
239
239
  credentials: args.credentials,
240
240
  billable: args.billable ?? false,
241
+ tags: {},
241
242
  harnessConversationIdentity: undefined,
242
243
  routing,
243
244
  reporter: args.reporter ?? createExperimentRoutedRuntimeReporter(),
@@ -252,6 +253,21 @@ async function prepareRuntimeExecution(args: {
252
253
  }): Promise<RuntimeExecutionPlan> {
253
254
  const { prepared } = args;
254
255
 
256
+ // A strict router never selects, leases, or falls back: the pinned pair or
257
+ // an error.
258
+ if (prepared.credentials.strict === true) {
259
+ const pinned = strictModelDecision(prepared);
260
+ prepared.reporter.attachServingDecision(pinned.decision);
261
+ prepared.reporter.attachDecisionDetails({
262
+ decision_source: "strict_model",
263
+ explicit_model: {
264
+ model: pinned.decision.selectedModel,
265
+ reasoning_effort: pinned.decision.reasoningEffort,
266
+ },
267
+ });
268
+ return selectedExecutionPlan(prepared, pinned);
269
+ }
270
+
255
271
  // A requested `dari/model/<id>` is served as chosen, before any lease.
256
272
  const explicit = args.requireSelectorDecision ? null : explicitModelDecision(prepared);
257
273
  if (explicit) {
@@ -621,6 +637,7 @@ function providerExecution(
621
637
  conversationId: prepared.routing.conversationId,
622
638
  costEstimates: prepared.routing.reportingCostEstimates,
623
639
  promptCacheKey: prepared.interaction.promptCacheKey,
640
+ tags: prepared.tags,
624
641
  });
625
642
  }
626
643
 
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env bun
2
2
  import { chmodSync, existsSync } from "node:fs";
3
3
  import { Database } from "bun:sqlite";
4
- import { getEnvApiKey } from "@mupt-ai/pi-ai/compat";
4
+ import { builtinModels } from "@mupt-ai/pi-ai/providers/all";
5
5
 
6
6
  import { bearerToken } from "../http/auth";
7
7
  import { errorResponse, HttpError } from "../http/errors";
@@ -45,13 +45,18 @@ export async function startSelfHostedGateway(env: Record<string, string> = proce
45
45
  db.exec("PRAGMA journal_mode = WAL");
46
46
  const subscriptions = new SubscriptionStore(db);
47
47
  const prefixes = new PrefixStore(db);
48
+ // Pi's provider auth reads each provider's standard key variable, such as
49
+ // OPENAI_API_KEY, from this environment only.
50
+ const piModels = builtinModels({
51
+ authContext: { env: async (name) => env[name], fileExists: async () => false },
52
+ });
48
53
 
49
54
  const platform = createSelfHostedPlatform({
50
55
  config,
51
56
  gatewayKeys,
52
57
  subscriptions,
53
58
  prefixes,
54
- providerApiKey: (provider) => getEnvApiKey(provider, env),
59
+ providerApiKey: async (provider) => (await piModels.getAuth(provider))?.auth.apiKey,
55
60
  env,
56
61
  fetch,
57
62
  });
@@ -41,7 +41,7 @@ export type SelfHostedResolverDeps = {
41
41
  conversationId?: string;
42
42
  }) => RouterPrefixHit[];
43
43
  // The provider's API key from its standard environment variable.
44
- providerApiKey: (provider: string) => string | undefined;
44
+ providerApiKey: (provider: string) => Promise<string | undefined>;
45
45
  env: Record<string, string | undefined>;
46
46
  fetch: OAuthFetch;
47
47
  };
@@ -92,7 +92,7 @@ export async function resolveSelfHostedRouter(
92
92
  if (subscription !== null && deps.subscriptions.list(subscription).length > 0) {
93
93
  exhausted.add(subscription);
94
94
  }
95
- const apiKey = providerApiKey(deps, provider);
95
+ const apiKey = await providerApiKey(deps, provider);
96
96
  credentials.set(provider, apiKey ? { key: apiKey, source: "user" } : null);
97
97
  }
98
98
 
@@ -137,7 +137,7 @@ export async function resolveSelfHostedRouter(
137
137
  }
138
138
  }
139
139
  for (const model of models) {
140
- const price = await registeredModelPrice(model.id);
140
+ const price = registeredModelPrice(model.id);
141
141
  if (price !== null) resolved.model_prices![model.id] = price;
142
142
  const provider = providerForModel(model.id);
143
143
  const endpoint = deps.config.providers[provider];
@@ -158,9 +158,9 @@ export async function resolveSelfHostedRouter(
158
158
 
159
159
  // Configured endpoints read their own key variable, or send a placeholder
160
160
  // when they take none; known providers use their standard variable.
161
- function providerApiKey(deps: SelfHostedResolverDeps, provider: string): string | undefined {
161
+ async function providerApiKey(deps: SelfHostedResolverDeps, provider: string): Promise<string | undefined> {
162
162
  const endpoint = deps.config.providers[provider];
163
- if (endpoint === undefined) return deps.providerApiKey(provider)?.trim() || undefined;
163
+ if (endpoint === undefined) return (await deps.providerApiKey(provider))?.trim() || undefined;
164
164
  if (endpoint.apiKeyEnv === undefined) return "no-key";
165
165
  return deps.env[endpoint.apiKeyEnv]?.trim() || undefined;
166
166
  }
@@ -188,7 +188,7 @@ export async function selectModel(
188
188
  }
189
189
 
190
190
  async function selectorContextWindowChars(selectorModel: string): Promise<number> {
191
- const contextWindow = await modelContextWindow(selectorModel);
191
+ const contextWindow = modelContextWindow(selectorModel);
192
192
  if (contextWindow === null) {
193
193
  throw new HttpError(
194
194
  500,
@@ -25,7 +25,7 @@ import {
25
25
  type ProviderIdentity,
26
26
  } from "../interaction/types";
27
27
  import { captureOperationalError } from "../observability/errors";
28
- import { gatewayPlatform } from "../platform/platform";
28
+ import { gatewayPlatform, type RequestTags } from "../platform/platform";
29
29
  import type {
30
30
  ProviderAttemptRecord,
31
31
  ProviderAttemptReport,
@@ -161,6 +161,7 @@ export class RequestReporter {
161
161
  private readonly args: {
162
162
  requestId: string;
163
163
  clientRequestId: string | null;
164
+ tags: RequestTags;
164
165
  credentials: ResolvedRouter;
165
166
  routerId: string;
166
167
  interaction: InteractionRequest;
@@ -428,6 +429,9 @@ export class RequestReporter {
428
429
  : null
429
430
  );
430
431
  const pricingKnown = selectedModel ? this.pricingKnown(selectedModel) : null;
432
+ const costSource = usage?.costSource === "provider"
433
+ ? "provider"
434
+ : pricingKnown === true ? "pricing" : null;
431
435
  const observability = extractInteractionObservability(a.interaction);
432
436
  const inputTokens = tokenCount(usage?.input) ?? 0;
433
437
  const retainedClientRequest = encodeRetainedInteractionRequest(
@@ -676,10 +680,10 @@ export class RequestReporter {
676
680
  cache_write_tokens: tokenCount(usage?.cacheWrite),
677
681
  output_tokens: tokenCount(usage?.output),
678
682
  total_tokens: tokenCount(usage?.totalTokens),
679
- total_cost_usd:
680
- pricingKnown === true && isRecord(usage?.cost)
681
- ? finiteNumber(usage.cost.total)
682
- : null,
683
+ total_cost_usd: costSource === null || !isRecord(usage?.cost)
684
+ ? null
685
+ : finiteNumber(usage.cost.total),
686
+ cost_source: costSource,
683
687
  pricing_known: pricingKnown,
684
688
  },
685
689
  distinctId:
@@ -690,6 +694,7 @@ export class RequestReporter {
690
694
  isNewConversation: a.isNewConversation,
691
695
  zdr: a.zdr,
692
696
  harnessConversationIdentity: a.harnessConversationIdentity,
697
+ tags: a.tags,
693
698
  });
694
699
  } catch (error) {
695
700
  console.error("routing report assembly failed", error);
@@ -4,7 +4,7 @@ import { randomUUID } from "node:crypto";
4
4
  import { HttpError } from "../http/errors";
5
5
  import { executionProviderForModel, keySourceForModel } from "../models/execution_provider";
6
6
  import { captureOperationalError } from "../observability/errors";
7
- import { gatewayPlatform } from "../platform/platform";
7
+ import { gatewayPlatform, type RequestTags } from "../platform/platform";
8
8
  import type { ProviderKeySource, ResolvedRouter } from "../types";
9
9
 
10
10
  export type RoutingUsageContext = {
@@ -12,6 +12,7 @@ export type RoutingUsageContext = {
12
12
  routerId: string;
13
13
  provider: Provider;
14
14
  model: string;
15
+ tags: RequestTags;
15
16
  startedAt: Date;
16
17
  };
17
18
 
@@ -75,6 +76,7 @@ export function usageContextFor(args: {
75
76
  routerId: string;
76
77
  selectedModel: string;
77
78
  keySource: ProviderKeySource;
79
+ tags: RequestTags;
78
80
  }): RoutingUsageContext | undefined {
79
81
  if (!args.billable || args.keySource !== "managed") return undefined;
80
82
  if (args.credentials.credit_exhausted) throw creditExhaustedError();
@@ -83,6 +85,7 @@ export function usageContextFor(args: {
83
85
  routerId: args.routerId,
84
86
  provider: executionProviderForModel(args.credentials, args.selectedModel),
85
87
  model: args.selectedModel,
88
+ tags: args.tags,
86
89
  startedAt: new Date(),
87
90
  };
88
91
  }
@@ -143,6 +146,7 @@ function usagePayload(context: RoutingUsageContext, message: Record<string, unkn
143
146
  total_cost_usd: usd(cost.total) ?? "0",
144
147
  started_at: context.startedAt.toISOString(),
145
148
  ended_at: new Date().toISOString(),
149
+ tags: context.tags,
146
150
  };
147
151
  }
148
152
 
package/src/types.ts CHANGED
@@ -42,6 +42,14 @@ export type AwsAssumeRoleProviderAuth = {
42
42
  // Authentication a provider call can use directly.
43
43
  export type ResolvedProviderAuth = BearerProviderAuth | AwsSigV4ProviderAuth;
44
44
 
45
+ // A bearer credential an embedding platform supplies for one request through
46
+ // GatewayPlatform.resolveProviderCredential, optionally with its own endpoint.
47
+ export type ProviderCredential = {
48
+ apiKey: string;
49
+ baseUrl?: string;
50
+ headers?: Record<string, string>;
51
+ };
52
+
45
53
  export type ProviderAuth = ResolvedProviderAuth | AwsAssumeRoleProviderAuth;
46
54
 
47
55
  export type AwsProviderAuth = AwsSigV4ProviderAuth | AwsAssumeRoleProviderAuth;
@@ -93,12 +101,19 @@ export type ResolvedRouter = {
93
101
  provider_keys: Partial<Record<Provider, string>>;
94
102
  provider_auth?: Partial<Record<Provider, AwsProviderAuth>>;
95
103
  provider_key_sources?: Partial<Record<Provider, ProviderKeySource>>;
104
+ // Model -> the platform-supplied credential that replaces the provider key
105
+ // above for this request. Filled from resolveProviderCredential.
106
+ provider_credentials?: Record<string, ProviderCredential>;
96
107
  // Execution provider -> the user's subscription credential serving it.
97
108
  subscription_credential_ids?: Partial<Record<Provider, string>>;
98
109
  zdr?: boolean;
99
110
  allow_long_context?: boolean;
100
111
  speculative_routing?: boolean;
101
112
  primary_retries?: number;
113
+ // Serve only the requested `dari/model/<id>` at the requested thinking
114
+ // level: no selector, lease, fallback, or personal subscription, and an
115
+ // error for anything else, whatever the client sends.
116
+ strict?: boolean;
102
117
  model_fallback_enabled?: boolean;
103
118
  fallback_requires_different_provider?: boolean;
104
119
  evals?: RouterEval[];