@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 +8 -1
- package/package.json +3 -1
- package/src/bun.ts +4 -0
- package/src/context/routing_context.ts +1 -1
- package/src/index.ts +5 -4
- package/src/ingress/models.ts +1 -1
- package/src/interaction/types.ts +3 -0
- package/src/platform/platform.ts +33 -1
- package/src/providers/pi.ts +102 -28
- package/src/providers/pi_options.ts +273 -443
- package/src/providers/pi_output.ts +1 -0
- package/src/providers/provider_call.ts +24 -5
- package/src/providers/provider_reported_cost.ts +138 -0
- package/src/providers/stream_finalization.ts +1 -1
- package/src/runtime/explicit_model.ts +27 -0
- package/src/runtime/prepare_routed_completion.ts +37 -1
- package/src/runtime/request_tags.ts +34 -0
- package/src/runtime/routed_completion.ts +2 -0
- package/src/runtime/routed_runtime.ts +18 -1
- package/src/self_host/main.ts +7 -2
- package/src/self_host/resolve.ts +5 -5
- package/src/strategies/routing.ts +1 -1
- package/src/telemetry/report.ts +10 -5
- package/src/telemetry/usage.ts +5 -1
- package/src/types.ts +15 -0
|
@@ -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
|
|
113
|
-
const
|
|
114
|
-
|
|
115
|
-
|
|
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
|
-
|
|
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
|
|
package/src/self_host/main.ts
CHANGED
|
@@ -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 {
|
|
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) =>
|
|
59
|
+
providerApiKey: async (provider) => (await piModels.getAuth(provider))?.auth.apiKey,
|
|
55
60
|
env,
|
|
56
61
|
fetch,
|
|
57
62
|
});
|
package/src/self_host/resolve.ts
CHANGED
|
@@ -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 =
|
|
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 =
|
|
191
|
+
const contextWindow = modelContextWindow(selectorModel);
|
|
192
192
|
if (contextWindow === null) {
|
|
193
193
|
throw new HttpError(
|
|
194
194
|
500,
|
package/src/telemetry/report.ts
CHANGED
|
@@ -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
|
-
|
|
681
|
-
|
|
682
|
-
|
|
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);
|
package/src/telemetry/usage.ts
CHANGED
|
@@ -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[];
|