@mupt-ai/dari-gateway 0.3.3 → 0.3.4
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 +4 -4
- package/dari-gateway.example.yaml +1 -1
- package/dist/context/interaction_fingerprint_format.d.ts +4 -0
- package/dist/context/interaction_fingerprint_format.js +3 -0
- package/dist/context/provider_prompt_accounting.js +52 -4
- package/dist/context/routing_context.d.ts +6 -1
- package/dist/context/routing_context.js +40 -4
- package/dist/ingress/anthropic_messages.js +12 -0
- package/dist/ingress/codex.js +8 -5
- package/dist/interaction/capabilities.d.ts +1 -0
- package/dist/interaction/capabilities.js +6 -0
- package/dist/interaction/document.d.ts +2 -0
- package/dist/interaction/document.js +8 -0
- package/dist/interaction/types.d.ts +7 -1
- package/dist/observability/errors.js +19 -0
- package/dist/platform/platform.d.ts +14 -0
- package/dist/protocol/input/anthropic_messages.js +74 -1
- package/dist/protocol/input/chat_completions.js +6 -0
- package/dist/protocol/input/chat_completions_validation.js +8 -0
- package/dist/protocol/input/openai_responses.js +8 -0
- package/dist/protocol/output/anthropic_messages_output.js +7 -0
- package/dist/providers/key_check.d.ts +31 -0
- package/dist/providers/key_check.js +54 -0
- package/dist/providers/model_capabilities.d.ts +2 -0
- package/dist/providers/model_capabilities.js +15 -0
- package/dist/providers/pi_context.js +13 -5
- package/dist/runtime/prepare_routed_completion.js +8 -1
- package/dist/self_host/config.d.ts +1 -1
- package/dist/self_host/main.js +32 -3
- package/dist/self_host/resolve.d.ts +1 -0
- package/dist/self_host/resolve.js +3 -3
- package/dist/strategies/selector_projection.js +3 -0
- package/dist/telemetry/report.d.ts +3 -1
- package/dist/telemetry/report.js +23 -0
- package/package.json +2 -2
- package/src/context/interaction_fingerprint_format.ts +4 -0
- package/src/context/provider_prompt_accounting.ts +52 -6
- package/src/context/routing_context.ts +56 -2
- package/src/ingress/anthropic_messages.ts +15 -1
- package/src/ingress/codex.ts +8 -5
- package/src/interaction/capabilities.ts +7 -0
- package/src/interaction/document.ts +8 -0
- package/src/interaction/types.ts +8 -1
- package/src/observability/errors.ts +21 -0
- package/src/platform/platform.ts +22 -0
- package/src/protocol/input/anthropic_messages.ts +71 -1
- package/src/protocol/input/chat_completions.ts +6 -0
- package/src/protocol/input/chat_completions_validation.ts +8 -0
- package/src/protocol/input/openai_responses.ts +13 -0
- package/src/protocol/output/anthropic_messages_output.ts +7 -0
- package/src/providers/key_check.ts +76 -0
- package/src/providers/model_capabilities.ts +18 -0
- package/src/providers/pi_context.ts +14 -5
- package/src/runtime/prepare_routed_completion.ts +12 -1
- package/src/self_host/config.ts +1 -1
- package/src/self_host/main.ts +39 -3
- package/src/self_host/resolve.ts +3 -3
- package/src/strategies/selector_projection.ts +2 -0
- package/src/telemetry/report.ts +34 -1
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
const PDF_DATA_URL_PREFIX = "data:application/pdf;base64,";
|
|
2
|
+
|
|
3
|
+
/** The base64 payload of a PDF data URL, or undefined for anything else. */
|
|
4
|
+
export function parsePdfDataUrl(url: string): string | undefined {
|
|
5
|
+
if (!url.startsWith(PDF_DATA_URL_PREFIX)) return undefined;
|
|
6
|
+
const data = url.slice(PDF_DATA_URL_PREFIX.length);
|
|
7
|
+
return data.length > 0 ? data : undefined;
|
|
8
|
+
}
|
package/src/interaction/types.ts
CHANGED
|
@@ -53,12 +53,19 @@ export type InteractionImage = {
|
|
|
53
53
|
detail?: "auto" | "low" | "high" | "original";
|
|
54
54
|
};
|
|
55
55
|
|
|
56
|
+
/** A base64-encoded PDF. Only Anthropic Messages targets accept documents. */
|
|
57
|
+
export type InteractionDocument = {
|
|
58
|
+
type: "document";
|
|
59
|
+
mimeType: "application/pdf";
|
|
60
|
+
data: string;
|
|
61
|
+
};
|
|
62
|
+
|
|
56
63
|
export type InteractionToolReference = {
|
|
57
64
|
type: "tool_reference";
|
|
58
65
|
toolName: string;
|
|
59
66
|
};
|
|
60
67
|
|
|
61
|
-
export type InteractionContent = InteractionText | InteractionImage | InteractionToolReference;
|
|
68
|
+
export type InteractionContent = InteractionText | InteractionImage | InteractionDocument | InteractionToolReference;
|
|
62
69
|
|
|
63
70
|
export type InteractionMessage = {
|
|
64
71
|
type: "message";
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { HttpError } from "../http/errors.js";
|
|
1
2
|
import {
|
|
2
3
|
gatewayPlatform,
|
|
3
4
|
type OperationalErrorContext,
|
|
@@ -10,9 +11,29 @@ export function captureRequestError(
|
|
|
10
11
|
error: unknown,
|
|
11
12
|
context: RequestErrorContext,
|
|
12
13
|
): void {
|
|
14
|
+
logUnsupportedInput(error, context);
|
|
13
15
|
gatewayPlatform().captureRequestError(error, context);
|
|
14
16
|
}
|
|
15
17
|
|
|
18
|
+
/* Platforms report only 5xx, so a client sending a block the gateway cannot
|
|
19
|
+
carry would otherwise be invisible. Log the code and parameter path, never
|
|
20
|
+
request content. */
|
|
21
|
+
function logUnsupportedInput(error: unknown, context: RequestErrorContext): void {
|
|
22
|
+
if (!(error instanceof HttpError) || error.status >= 500) return;
|
|
23
|
+
if (!error.code.startsWith("unsupported_") && !error.code.startsWith("no_")) return;
|
|
24
|
+
console.warn(
|
|
25
|
+
"routing request unsupported input",
|
|
26
|
+
JSON.stringify({
|
|
27
|
+
router_id: context.routerId,
|
|
28
|
+
request_id: context.requestId ?? null,
|
|
29
|
+
source_protocol: context.sourceProtocol,
|
|
30
|
+
error_code: error.code,
|
|
31
|
+
param: error.param ?? null,
|
|
32
|
+
message: error.message,
|
|
33
|
+
}),
|
|
34
|
+
);
|
|
35
|
+
}
|
|
36
|
+
|
|
16
37
|
/**
|
|
17
38
|
* Report failures deliberately caught outside the ingress exception path,
|
|
18
39
|
* including work after a response has started and process shutdown.
|
package/src/platform/platform.ts
CHANGED
|
@@ -138,6 +138,28 @@ export type RequestRecord = {
|
|
|
138
138
|
harnessConversationIdentity?: HarnessConversationIdentity;
|
|
139
139
|
// Parsed from the request's X-Dari-Tags header; empty when absent.
|
|
140
140
|
tags: RequestTags;
|
|
141
|
+
// The router's non-secret routing settings, for embedders that compare or
|
|
142
|
+
// replay routing decisions.
|
|
143
|
+
router: RequestRecordRouter;
|
|
144
|
+
// The model and reasoning pairs this request could be routed to, after
|
|
145
|
+
// capability, custom-rule, and cost pruning.
|
|
146
|
+
candidates: Array<{ model: string; reasoningEffort: ReasoningEffort }>;
|
|
147
|
+
// The session pin key this request routed under, when the router pins
|
|
148
|
+
// sessions and the request was pinnable (not strict, explicit, or
|
|
149
|
+
// background). Each harness subagent has its own key.
|
|
150
|
+
sessionPinKey?: string;
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
export type RequestRecordRouter = {
|
|
154
|
+
enabledModels: string[];
|
|
155
|
+
// Each enabled model's selectable reasoning levels: the router's
|
|
156
|
+
// configured levels, else the model's supported levels.
|
|
157
|
+
thinkingLevels: Record<string, ReasoningEffort[]>;
|
|
158
|
+
// The level a requested dari/model/<id> uses when the request sets none.
|
|
159
|
+
defaultThinkingLevels: Record<string, ReasoningEffort>;
|
|
160
|
+
strict: boolean;
|
|
161
|
+
speculativeRouting: boolean;
|
|
162
|
+
sessionPinning: boolean;
|
|
141
163
|
};
|
|
142
164
|
|
|
143
165
|
// Caller-supplied attribution labels (run, trial, setting), opaque to the
|
|
@@ -9,6 +9,7 @@ import { explicitModelId, isAnthropicRouterModel, unsupportedModelMessage } from
|
|
|
9
9
|
import type {
|
|
10
10
|
ClientTool,
|
|
11
11
|
InteractionContent,
|
|
12
|
+
InteractionDocument,
|
|
12
13
|
InteractionItem,
|
|
13
14
|
InteractionRequest,
|
|
14
15
|
InteractionResponseFormat,
|
|
@@ -134,6 +135,16 @@ function appendAnthropicMessage(
|
|
|
134
135
|
buffered.push(anthropicImage(rawBlock.source, `${param}.source`));
|
|
135
136
|
continue;
|
|
136
137
|
}
|
|
138
|
+
if (type === "document") {
|
|
139
|
+
if (role !== "user") throw invalidRequest("Anthropic document blocks are only supported on user messages.", param);
|
|
140
|
+
buffered.push(...anthropicDocument(rawBlock, param));
|
|
141
|
+
continue;
|
|
142
|
+
}
|
|
143
|
+
if (type === "search_result") {
|
|
144
|
+
if (role !== "user") throw invalidRequest("Anthropic search_result blocks are only supported on user messages.", param);
|
|
145
|
+
buffered.push(anthropicSearchResult(rawBlock, param));
|
|
146
|
+
continue;
|
|
147
|
+
}
|
|
137
148
|
if (type === "thinking" || type === "redacted_thinking") {
|
|
138
149
|
if (role !== "assistant") throw invalidRequest(`${type} blocks require the assistant role.`, param);
|
|
139
150
|
flushMessage();
|
|
@@ -240,6 +251,63 @@ function appendAnthropicMessage(
|
|
|
240
251
|
flushMessage();
|
|
241
252
|
}
|
|
242
253
|
|
|
254
|
+
/* PDFs stay native documents; they route only to models that read PDFs.
|
|
255
|
+
Text and content documents become ordinary parts so every model can read
|
|
256
|
+
them. Title and context travel as a leading text part. Citations are not
|
|
257
|
+
requested from the provider, so the citations setting is dropped. */
|
|
258
|
+
function anthropicDocument(block: Record<string, unknown>, param: string): InteractionContent[] {
|
|
259
|
+
const source = block.source;
|
|
260
|
+
const sourceParam = `${param}.source`;
|
|
261
|
+
if (!isRecord(source)) throw invalidRequest(`${sourceParam} must be an object.`, sourceParam);
|
|
262
|
+
const header = [optionalString(block.title, `${param}.title`), optionalString(block.context, `${param}.context`)]
|
|
263
|
+
.filter((line): line is string => line !== undefined && line.length > 0)
|
|
264
|
+
.join("\n");
|
|
265
|
+
const withHeader = (body: InteractionContent[]): InteractionContent[] =>
|
|
266
|
+
header ? [{ type: "text", text: `Document: ${header}` }, ...body] : body;
|
|
267
|
+
const type = requiredString(source.type, `${sourceParam}.type`);
|
|
268
|
+
if (type === "base64") {
|
|
269
|
+
const mediaType = requiredString(source.media_type, `${sourceParam}.media_type`);
|
|
270
|
+
if (mediaType !== "application/pdf") {
|
|
271
|
+
throw new HttpError(400, "Base64 document sources must be application/pdf.", "unsupported_document_source", `${sourceParam}.media_type`);
|
|
272
|
+
}
|
|
273
|
+
const document: InteractionDocument = { type: "document", mimeType: "application/pdf", data: requiredString(source.data, `${sourceParam}.data`) };
|
|
274
|
+
return withHeader([document]);
|
|
275
|
+
}
|
|
276
|
+
if (type === "text") {
|
|
277
|
+
return withHeader([{ type: "text", text: requiredStringAllowEmpty(source.data, `${sourceParam}.data`) }]);
|
|
278
|
+
}
|
|
279
|
+
if (type === "content") {
|
|
280
|
+
if (typeof source.content === "string") return withHeader([{ type: "text", text: source.content }]);
|
|
281
|
+
if (!Array.isArray(source.content)) throw invalidRequest(`${sourceParam}.content must be a string or an array.`, `${sourceParam}.content`);
|
|
282
|
+
return withHeader(source.content.map((part, index): InteractionContent => {
|
|
283
|
+
const partParam = `${sourceParam}.content.${index}`;
|
|
284
|
+
if (!isRecord(part)) throw invalidRequest(`${partParam} must be an object.`, partParam);
|
|
285
|
+
if (part.type === "text") return { type: "text", text: requiredStringAllowEmpty(part.text, `${partParam}.text`) };
|
|
286
|
+
if (part.type === "image") return anthropicImage(part.source, `${partParam}.source`);
|
|
287
|
+
throw new HttpError(400, `Document content block type '${String(part.type ?? "unknown")}' is not supported.`, "unsupported_content_block", partParam);
|
|
288
|
+
}));
|
|
289
|
+
}
|
|
290
|
+
throw new HttpError(
|
|
291
|
+
400,
|
|
292
|
+
`Anthropic document source type '${type}' is not supported; use a base64 PDF, text, or content source.`,
|
|
293
|
+
"unsupported_document_source",
|
|
294
|
+
`${sourceParam}.type`
|
|
295
|
+
);
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
// Search results are text with provenance; they reach every model as text.
|
|
299
|
+
function anthropicSearchResult(block: Record<string, unknown>, param: string): InteractionContent {
|
|
300
|
+
const title = requiredStringAllowEmpty(block.title, `${param}.title`);
|
|
301
|
+
const source = requiredStringAllowEmpty(block.source, `${param}.source`);
|
|
302
|
+
if (!Array.isArray(block.content)) throw invalidRequest(`${param}.content must be an array.`, `${param}.content`);
|
|
303
|
+
const body = block.content.map((part, index) => {
|
|
304
|
+
const partParam = `${param}.content.${index}`;
|
|
305
|
+
if (!isRecord(part) || part.type !== "text") throw invalidRequest(`${partParam} must be a text block.`, partParam);
|
|
306
|
+
return requiredStringAllowEmpty(part.text, `${partParam}.text`);
|
|
307
|
+
});
|
|
308
|
+
return { type: "text", text: [`Search result: ${title}`, `Source: ${source}`, "", ...body].join("\n") };
|
|
309
|
+
}
|
|
310
|
+
|
|
243
311
|
function anthropicImage(value: unknown, param: string): InteractionContent {
|
|
244
312
|
if (!isRecord(value)) throw invalidRequest(`${param} must be an object.`, param);
|
|
245
313
|
const type = requiredString(value.type, `${param}.type`);
|
|
@@ -306,13 +374,15 @@ function anthropicToolResultContent(
|
|
|
306
374
|
if (typeof value === "string") return { output: value, shape: "string" };
|
|
307
375
|
if (!Array.isArray(value)) throw invalidRequest(`${param} must be a string or an array.`, param);
|
|
308
376
|
return {
|
|
309
|
-
output: value.
|
|
377
|
+
output: value.flatMap((rawBlock, index): InteractionContent | InteractionContent[] => {
|
|
310
378
|
const blockParam = `${param}.${index}`;
|
|
311
379
|
if (!isRecord(rawBlock)) throw invalidRequest(`${blockParam} must be an object.`, blockParam);
|
|
312
380
|
if (rawBlock.type === "text") {
|
|
313
381
|
return { type: "text", text: requiredStringAllowEmpty(rawBlock.text, `${blockParam}.text`) };
|
|
314
382
|
}
|
|
315
383
|
if (rawBlock.type === "image") return anthropicImage(rawBlock.source, `${blockParam}.source`);
|
|
384
|
+
if (rawBlock.type === "document") return anthropicDocument(rawBlock, blockParam);
|
|
385
|
+
if (rawBlock.type === "search_result") return [anthropicSearchResult(rawBlock, blockParam)];
|
|
316
386
|
if (rawBlock.type === "tool_reference") {
|
|
317
387
|
return {
|
|
318
388
|
type: "tool_reference",
|
|
@@ -18,6 +18,7 @@ import type {
|
|
|
18
18
|
import { toolsByWireName } from "../../interaction/tool_identity.js";
|
|
19
19
|
import { decodeUntrustedProviderContinuationState } from "../../interaction/provider_continuation_state.js";
|
|
20
20
|
import { validateChatCompletionsRequest } from "./chat_completions_validation.js";
|
|
21
|
+
import { parsePdfDataUrl } from "../../interaction/document.js";
|
|
21
22
|
|
|
22
23
|
export function chatCompletionsToInteraction(request: ChatCompletionRequest): InteractionRequest {
|
|
23
24
|
validateChatCompletionsRequest(request);
|
|
@@ -220,6 +221,11 @@ function messageContent(content: ChatMessage["content"]): InteractionContent[] {
|
|
|
220
221
|
if (part.type === "tool_reference") {
|
|
221
222
|
return { type: "text", text: `Referenced tool: ${String(part.tool_name)}` };
|
|
222
223
|
}
|
|
224
|
+
if (part.type === "file") {
|
|
225
|
+
// Validation guarantees a base64 PDF data URL.
|
|
226
|
+
const fileData = String((part.file as Record<string, unknown>).file_data);
|
|
227
|
+
return { type: "document", mimeType: "application/pdf", data: parsePdfDataUrl(fileData)! };
|
|
228
|
+
}
|
|
223
229
|
const imageUrl = part.image_url as Record<string, unknown>;
|
|
224
230
|
const detail = imageUrl.detail;
|
|
225
231
|
return {
|
|
@@ -7,6 +7,7 @@ import {
|
|
|
7
7
|
isProtocolToolCallId,
|
|
8
8
|
TOOL_CALL_ID_MAX_LENGTH,
|
|
9
9
|
} from "../../interaction/tool_identity.js";
|
|
10
|
+
import { parsePdfDataUrl } from "../../interaction/document.js";
|
|
10
11
|
|
|
11
12
|
export function validateChatCompletionsRequest(request: ChatCompletionRequest): void {
|
|
12
13
|
if (request.model !== CANONICAL_ROUTER_MODEL && explicitModelId(request.model) === null) {
|
|
@@ -124,6 +125,13 @@ function validateMessageContent(content: unknown, messageIndex: number, role: st
|
|
|
124
125
|
}
|
|
125
126
|
return;
|
|
126
127
|
}
|
|
128
|
+
if (part.type === "file") {
|
|
129
|
+
const fileData = isRecord(part.file) ? part.file.file_data : undefined;
|
|
130
|
+
if (typeof fileData !== "string" || parsePdfDataUrl(fileData) === undefined) {
|
|
131
|
+
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`);
|
|
132
|
+
}
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
127
135
|
if (part.type === "image_url") {
|
|
128
136
|
const imageUrl = part.image_url;
|
|
129
137
|
const url = isRecord(imageUrl) && typeof imageUrl.url === "string" ? imageUrl.url : "";
|
|
@@ -21,6 +21,7 @@ import type {
|
|
|
21
21
|
InteractionToolChoice,
|
|
22
22
|
ToolKind,
|
|
23
23
|
} from "../../interaction/types.js";
|
|
24
|
+
import { parsePdfDataUrl } from "../../interaction/document.js";
|
|
24
25
|
|
|
25
26
|
const UNSUPPORTED_INPUT_ITEM_TYPES = new Set([
|
|
26
27
|
"additional_tools",
|
|
@@ -470,6 +471,18 @@ function responseContent(value: unknown, param: string): InteractionContent[] {
|
|
|
470
471
|
...(detail !== undefined ? { detail } : {}),
|
|
471
472
|
};
|
|
472
473
|
}
|
|
474
|
+
if (part.type === "input_file") {
|
|
475
|
+
const data = typeof part.file_data === "string" ? parsePdfDataUrl(part.file_data) : undefined;
|
|
476
|
+
if (data === undefined) {
|
|
477
|
+
throw new HttpError(
|
|
478
|
+
400,
|
|
479
|
+
"Responses input_file parts must carry a base64 PDF data URL in file_data; file_id and file_url are not supported.",
|
|
480
|
+
"unsupported_file_source",
|
|
481
|
+
`${param}.${index}`
|
|
482
|
+
);
|
|
483
|
+
}
|
|
484
|
+
return { type: "document", mimeType: "application/pdf", data };
|
|
485
|
+
}
|
|
473
486
|
throw new HttpError(400, `Responses content part type '${String(part.type ?? "unknown")}' is not supported.`, "unsupported_response_content", `${param}.${index}.type`);
|
|
474
487
|
});
|
|
475
488
|
}
|
|
@@ -54,6 +54,13 @@ export function anthropicContent(output: InteractionOutput): Array<Record<string
|
|
|
54
54
|
content.push({ type: "text", text: `Referenced tool: ${part.toolName}` });
|
|
55
55
|
continue;
|
|
56
56
|
}
|
|
57
|
+
if (part.type === "document") {
|
|
58
|
+
content.push({
|
|
59
|
+
type: "document",
|
|
60
|
+
source: { type: "base64", media_type: part.mimeType, data: part.data },
|
|
61
|
+
});
|
|
62
|
+
continue;
|
|
63
|
+
}
|
|
57
64
|
const image = parseDataImageUrl(part.url);
|
|
58
65
|
if (!image) throw new Error("Anthropic output images must use base64 data URLs.");
|
|
59
66
|
content.push({
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { getBuiltinModels } from "@mupt-ai/pi-ai/providers/all";
|
|
2
|
+
|
|
3
|
+
const CHECK_TIMEOUT_MS = 10_000;
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* - `valid`: the provider accepted the key.
|
|
7
|
+
* - `rejected`: the provider refused it (401 or 403).
|
|
8
|
+
* - `unchecked`: no answer either way, such as a network error, an outage, or
|
|
9
|
+
* a provider without a known check. Callers treat this as not rejected so a
|
|
10
|
+
* provider outage never blocks a key.
|
|
11
|
+
*/
|
|
12
|
+
export type ProviderKeyCheckResult =
|
|
13
|
+
| { result: "valid" }
|
|
14
|
+
| { result: "rejected"; status: number }
|
|
15
|
+
| { result: "unchecked" };
|
|
16
|
+
|
|
17
|
+
// A provider endpoint outside the model catalog, such as a self-run server.
|
|
18
|
+
export type ProviderKeyEndpoint = { api: string; baseUrl: string };
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Ask a provider whether an API key works, with the cheapest authenticated
|
|
22
|
+
* request its wire API offers: list models, or, where listing models needs
|
|
23
|
+
* no key, an endpoint that does. The self-hosted gateway runs this at startup
|
|
24
|
+
* and the hosted control plane before it saves a key.
|
|
25
|
+
*/
|
|
26
|
+
export async function checkProviderKey(args: {
|
|
27
|
+
provider: string;
|
|
28
|
+
key: string;
|
|
29
|
+
endpoint?: ProviderKeyEndpoint;
|
|
30
|
+
fetch: typeof fetch;
|
|
31
|
+
}): Promise<ProviderKeyCheckResult> {
|
|
32
|
+
const check = keyCheckRequest(args.provider, args.key, args.endpoint);
|
|
33
|
+
if (check === null) return { result: "unchecked" };
|
|
34
|
+
try {
|
|
35
|
+
const response = await args.fetch(check.url, {
|
|
36
|
+
headers: check.headers,
|
|
37
|
+
signal: AbortSignal.timeout(CHECK_TIMEOUT_MS),
|
|
38
|
+
});
|
|
39
|
+
await response.body?.cancel();
|
|
40
|
+
if (response.status === 401 || response.status === 403) {
|
|
41
|
+
return { result: "rejected", status: response.status };
|
|
42
|
+
}
|
|
43
|
+
return response.ok ? { result: "valid" } : { result: "unchecked" };
|
|
44
|
+
} catch {
|
|
45
|
+
return { result: "unchecked" };
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function keyCheckRequest(
|
|
50
|
+
provider: string,
|
|
51
|
+
key: string,
|
|
52
|
+
endpoint: ProviderKeyEndpoint | undefined,
|
|
53
|
+
): { url: string; headers: Record<string, string> } | null {
|
|
54
|
+
const bearer = { authorization: `Bearer ${key}` };
|
|
55
|
+
if (endpoint === undefined) {
|
|
56
|
+
if (provider === "openrouter") return { url: "https://openrouter.ai/api/v1/key", headers: bearer };
|
|
57
|
+
if (provider === "vercel-ai-gateway") return { url: "https://ai-gateway.vercel.sh/v1/credits", headers: bearer };
|
|
58
|
+
}
|
|
59
|
+
const target = endpoint
|
|
60
|
+
?? (getBuiltinModels(provider as never) as unknown as ProviderKeyEndpoint[] | undefined)?.[0];
|
|
61
|
+
if (target === undefined || !URL.canParse(target.baseUrl)) return null;
|
|
62
|
+
const base = target.baseUrl.replace(/\/+$/, "");
|
|
63
|
+
switch (target.api) {
|
|
64
|
+
case "anthropic-messages":
|
|
65
|
+
return { url: `${base}/v1/models`, headers: { "x-api-key": key, "anthropic-version": "2023-06-01" } };
|
|
66
|
+
case "google-generative-ai":
|
|
67
|
+
return { url: `${base}/models`, headers: { "x-goog-api-key": key } };
|
|
68
|
+
case "mistral-conversations":
|
|
69
|
+
return { url: `${base}/v1/models`, headers: bearer };
|
|
70
|
+
case "openai-completions":
|
|
71
|
+
case "openai-responses":
|
|
72
|
+
return { url: `${base}/models`, headers: bearer };
|
|
73
|
+
default:
|
|
74
|
+
return null;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
@@ -14,6 +14,24 @@ export function modelSupportsImageInput(metadata: ModelCapabilityMetadata): bool
|
|
|
14
14
|
return Array.isArray(metadata.model.input) && metadata.model.input.includes("image");
|
|
15
15
|
}
|
|
16
16
|
|
|
17
|
+
/* PDFs are sent natively only where the model owner documents PDF input on
|
|
18
|
+
that API. The same API can serve other owners (xAI and Meta on Responses,
|
|
19
|
+
GPT on Bedrock) whose PDF support is unverified, so both must match. */
|
|
20
|
+
const DOCUMENT_INPUT_APIS_BY_OWNER: Readonly<Record<string, ReadonlySet<string>>> = {
|
|
21
|
+
anthropic: new Set(["anthropic-messages", "bedrock-converse-stream"]),
|
|
22
|
+
openai: new Set(["openai-responses", "azure-openai-responses", "openai-codex-responses"]),
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
export function apiSupportsDocumentInput(api: string): boolean {
|
|
26
|
+
return Object.values(DOCUMENT_INPUT_APIS_BY_OWNER).some((apis) => apis.has(api));
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function modelSupportsDocumentInput(modelId: string, metadata: ModelCapabilityMetadata): boolean {
|
|
30
|
+
const owner = modelId.split("/", 1)[0] ?? "";
|
|
31
|
+
const api = metadata.model.api;
|
|
32
|
+
return typeof api === "string" && DOCUMENT_INPUT_APIS_BY_OWNER[owner]?.has(api) === true;
|
|
33
|
+
}
|
|
34
|
+
|
|
17
35
|
export function providerApiCanSerializeResponseFormat(metadata: ModelCapabilityMetadata): boolean {
|
|
18
36
|
return (
|
|
19
37
|
metadata.model.api === "openai-responses" ||
|
|
@@ -17,6 +17,7 @@ import {
|
|
|
17
17
|
sameProviderIdentity,
|
|
18
18
|
} from "../interaction/provider_continuation_state.js";
|
|
19
19
|
import { isOpenAIResponsesApi } from "./model_capabilities.js";
|
|
20
|
+
import { apiSupportsDocumentInput } from "./model_capabilities.js";
|
|
20
21
|
|
|
21
22
|
const UNKNOWN_SOURCE: ProviderIdentity = {
|
|
22
23
|
provider: "dari",
|
|
@@ -104,7 +105,7 @@ export function buildContext(
|
|
|
104
105
|
sawConversationItem = true;
|
|
105
106
|
if (item.type === "message" && item.role === "user") {
|
|
106
107
|
flushAssistant();
|
|
107
|
-
appendUserMessage(messages, userContent(item.content, item.contentShape), timestamp);
|
|
108
|
+
appendUserMessage(messages, userContent(item.content, item.contentShape, target), timestamp);
|
|
108
109
|
continue;
|
|
109
110
|
}
|
|
110
111
|
if (item.type === "tool_result") {
|
|
@@ -297,10 +298,11 @@ function thinkingContent(
|
|
|
297
298
|
|
|
298
299
|
function userContent(
|
|
299
300
|
content: InteractionContent[],
|
|
300
|
-
shape: "string" | "parts" | "null" | "absent"
|
|
301
|
+
shape: "string" | "parts" | "null" | "absent",
|
|
302
|
+
target: ProviderIdentity,
|
|
301
303
|
): string | Array<Record<string, unknown>> {
|
|
302
304
|
if (shape !== "parts") return content.filter((part) => part.type === "text").map((part) => part.text).join("");
|
|
303
|
-
return content.map(contentToPi);
|
|
305
|
+
return content.map((part) => contentToPi(part, target));
|
|
304
306
|
}
|
|
305
307
|
|
|
306
308
|
function appendPositionalNote(
|
|
@@ -347,12 +349,19 @@ function toolResultContent(
|
|
|
347
349
|
const filtered = target.api === "anthropic-messages"
|
|
348
350
|
? content.filter((part) => part.type !== "tool_reference")
|
|
349
351
|
: content;
|
|
350
|
-
return filtered.map(contentToPi);
|
|
352
|
+
return filtered.map((part) => contentToPi(part, target));
|
|
351
353
|
}
|
|
352
354
|
|
|
353
|
-
function contentToPi(content: InteractionContent): Record<string, unknown> {
|
|
355
|
+
function contentToPi(content: InteractionContent, target: ProviderIdentity): Record<string, unknown> {
|
|
354
356
|
if (content.type === "text") return { type: "text", text: content.text };
|
|
355
357
|
if (content.type === "tool_reference") return { type: "text", text: `Referenced tool: ${content.toolName}` };
|
|
358
|
+
if (content.type === "document") {
|
|
359
|
+
// Routing keeps PDFs on document-capable models; this guards direct callers.
|
|
360
|
+
if (!apiSupportsDocumentInput(target.api)) {
|
|
361
|
+
throw new HttpError(400, `PDF document input is not supported by the ${target.api} API.`, "unsupported_input_modality", "input.document");
|
|
362
|
+
}
|
|
363
|
+
return { type: "document", mimeType: content.mimeType, data: content.data };
|
|
364
|
+
}
|
|
356
365
|
const image = parseDataImageUrl(content.url);
|
|
357
366
|
if (!image) {
|
|
358
367
|
throw new HttpError(400, "Only base64 data URLs are supported for routed image input.", "invalid_request_error", "input.image_url");
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import type { CandidatePruningAudit } from "@mupt-ai/dari-router/policy-engine";
|
|
2
2
|
|
|
3
3
|
import { interactionConversationPrefix } from "../context/interaction_fingerprint.js";
|
|
4
|
+
import { clampRequestedEffort } from "../context/routing_context.js";
|
|
5
|
+
import { modelMetadataLookup } from "../providers/pi.js";
|
|
4
6
|
import {
|
|
5
7
|
buildRoutingContext,
|
|
6
8
|
derivePrefixLookupHashes,
|
|
@@ -134,7 +136,14 @@ export async function prepareRoutedCompletion(args: {
|
|
|
134
136
|
}
|
|
135
137
|
const access = strictProviderAccess(credentials, args.interaction);
|
|
136
138
|
credentials = access.credentials;
|
|
137
|
-
args = {
|
|
139
|
+
args = {
|
|
140
|
+
...args,
|
|
141
|
+
interaction: clampRequestedEffort(
|
|
142
|
+
access.interaction,
|
|
143
|
+
credentials,
|
|
144
|
+
modelMetadataLookup(credentials.model_configs),
|
|
145
|
+
),
|
|
146
|
+
};
|
|
138
147
|
credentials = await withProviderCredentials(credentials, args.apiKey, args.clientHarness);
|
|
139
148
|
const billable = true;
|
|
140
149
|
const routing = await buildRoutingContext({
|
|
@@ -190,6 +199,8 @@ export async function prepareRoutedCompletion(args: {
|
|
|
190
199
|
identityHit: routing.identityHit,
|
|
191
200
|
hits: routing.hits,
|
|
192
201
|
costEstimates: routing.reportingCostEstimates,
|
|
202
|
+
candidates: candidateResolution.candidates,
|
|
203
|
+
sessionPinKey: sessionPin?.key,
|
|
193
204
|
pruning: reportedPruning(
|
|
194
205
|
candidateResolution.pruning,
|
|
195
206
|
routing.imageCapabilityPrunedModels,
|
package/src/self_host/config.ts
CHANGED
|
@@ -12,7 +12,7 @@ import {
|
|
|
12
12
|
* default:
|
|
13
13
|
* models:
|
|
14
14
|
* - anthropic/claude-sonnet-5-5
|
|
15
|
-
* - id: openai/gpt-
|
|
15
|
+
* - id: openai/gpt-6.1-sol
|
|
16
16
|
* thinking: [medium, high]
|
|
17
17
|
* subscriptions: true # serve with connected Claude Code / ChatGPT plans
|
|
18
18
|
* sessionPinning: false # route each session and subagent once, then keep its model
|
package/src/self_host/main.ts
CHANGED
|
@@ -10,9 +10,11 @@ import { installShutdownHandlers, startServer } from "../http/serve.js";
|
|
|
10
10
|
import { createAutoRoutingSelectorBackend } from "../strategies/auto_routing_selector.js";
|
|
11
11
|
import { createPrioritySelectorBackend } from "../strategies/priority_selector.js";
|
|
12
12
|
import type { SelectorBackend } from "../strategies/routing.js";
|
|
13
|
-
import { loadGatewayConfig } from "./config.js";
|
|
14
13
|
import { createSelfHostedPlatform } from "./platform.js";
|
|
15
|
-
import {
|
|
14
|
+
import { providerForModel } from "@mupt-ai/dari-router/policy-engine";
|
|
15
|
+
import { checkProviderKey } from "../providers/key_check.js";
|
|
16
|
+
import { loadGatewayConfig, type GatewayConfig } from "./config.js";
|
|
17
|
+
import { configuredProviderApiKey, gatewayKeyIndex, providersWithoutCredentials } from "./resolve.js";
|
|
16
18
|
import { PrefixStore } from "./prefix_store.js";
|
|
17
19
|
import { SessionPinStore } from "./session_pin_store.js";
|
|
18
20
|
import { handleSubscriptionRequest, SubscriptionStore } from "./subscriptions/index.js";
|
|
@@ -76,6 +78,8 @@ export async function startSelfHostedGateway(env: Record<string, string> = proce
|
|
|
76
78
|
);
|
|
77
79
|
}
|
|
78
80
|
}
|
|
81
|
+
await checkConfiguredProviderKeys(config, (provider) =>
|
|
82
|
+
configuredProviderApiKey({ config, providerApiKey, env }, provider));
|
|
79
83
|
const autoRoutingKey = env.DARI_AUTO_ROUTING_API_KEY?.trim();
|
|
80
84
|
const selectorBackend: SelectorBackend = autoRoutingKey
|
|
81
85
|
? createAutoRoutingSelectorBackend({
|
|
@@ -109,7 +113,8 @@ export async function startSelfHostedGateway(env: Record<string, string> = proce
|
|
|
109
113
|
"dari-gateway listening",
|
|
110
114
|
JSON.stringify({
|
|
111
115
|
hostname,
|
|
112
|
-
port,
|
|
116
|
+
// The bound port, so PORT=0 reports the port the OS assigned.
|
|
117
|
+
port: server.port,
|
|
113
118
|
routers: Object.keys(config.routers),
|
|
114
119
|
auto_routing: autoRoutingKey ? "dari" : "priority",
|
|
115
120
|
}),
|
|
@@ -117,6 +122,37 @@ export async function startSelfHostedGateway(env: Record<string, string> = proce
|
|
|
117
122
|
return { server, close: () => db.close() };
|
|
118
123
|
}
|
|
119
124
|
|
|
125
|
+
/**
|
|
126
|
+
* Check each provider API key the routers use before serving: a key its
|
|
127
|
+
* provider rejects fails startup, and one no check could answer is reported.
|
|
128
|
+
*/
|
|
129
|
+
async function checkConfiguredProviderKeys(
|
|
130
|
+
config: GatewayConfig,
|
|
131
|
+
providerApiKey: (provider: string) => Promise<string | undefined>,
|
|
132
|
+
): Promise<void> {
|
|
133
|
+
const providers = new Set(
|
|
134
|
+
Object.values(config.routers).flatMap((router) => router.models.map((model) => providerForModel(model.id))),
|
|
135
|
+
);
|
|
136
|
+
const checks = await Promise.all([...providers].map(async (provider) => {
|
|
137
|
+
const endpoint = config.providers[provider];
|
|
138
|
+
// Endpoints configured without a key variable take no key.
|
|
139
|
+
if (endpoint !== undefined && endpoint.apiKeyEnv === undefined) return null;
|
|
140
|
+
const key = await providerApiKey(provider);
|
|
141
|
+
if (key === undefined) return null;
|
|
142
|
+
return { provider, ...(await checkProviderKey({ provider, key, ...(endpoint ? { endpoint } : {}), fetch })) };
|
|
143
|
+
}));
|
|
144
|
+
const rejected = checks.flatMap((check) => check?.result === "rejected" ? [`${check.provider} (${check.status})`] : []);
|
|
145
|
+
if (rejected.length > 0) {
|
|
146
|
+
throw new Error(
|
|
147
|
+
`Provider API keys were rejected: ${rejected.join(", ")}. Fix or unset each key before starting the gateway.`,
|
|
148
|
+
);
|
|
149
|
+
}
|
|
150
|
+
const unchecked = checks.flatMap((check) => check?.result === "unchecked" ? [check.provider] : []);
|
|
151
|
+
if (unchecked.length > 0) {
|
|
152
|
+
console.warn("dari-gateway could not check these provider API keys", JSON.stringify({ providers: unchecked }));
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
120
156
|
if (import.meta.main) {
|
|
121
157
|
// Startup errors are configuration mistakes: print the message, not a stack.
|
|
122
158
|
const started = await startSelfHostedGateway().catch((error: unknown) => {
|
package/src/self_host/resolve.ts
CHANGED
|
@@ -86,7 +86,7 @@ export async function resolveSelfHostedRouter(
|
|
|
86
86
|
if (subscription !== null && deps.subscriptions.list(subscription).length > 0) {
|
|
87
87
|
exhausted.add(subscription);
|
|
88
88
|
}
|
|
89
|
-
const apiKey = await
|
|
89
|
+
const apiKey = await configuredProviderApiKey(deps, provider);
|
|
90
90
|
credentials.set(provider, apiKey ? { key: apiKey, source: "user" } : null);
|
|
91
91
|
}
|
|
92
92
|
|
|
@@ -154,7 +154,7 @@ export async function resolveSelfHostedRouter(
|
|
|
154
154
|
|
|
155
155
|
// Configured endpoints read their own key variable, or send a placeholder
|
|
156
156
|
// when they take none; known providers use their standard variable.
|
|
157
|
-
async function
|
|
157
|
+
export async function configuredProviderApiKey(
|
|
158
158
|
deps: Pick<SelfHostedResolverDeps, "config" | "providerApiKey" | "env">,
|
|
159
159
|
provider: string,
|
|
160
160
|
): Promise<string | undefined> {
|
|
@@ -226,7 +226,7 @@ export async function providersWithoutCredentials(
|
|
|
226
226
|
: [];
|
|
227
227
|
const connected = plans.some((plan) => plan !== null && deps.subscriptions.list(plan).length > 0);
|
|
228
228
|
if (connected) continue;
|
|
229
|
-
const apiKey = await
|
|
229
|
+
const apiKey = await configuredProviderApiKey(deps, provider);
|
|
230
230
|
if (apiKey === undefined) missing.push(provider);
|
|
231
231
|
}
|
|
232
232
|
return missing;
|
|
@@ -190,6 +190,8 @@ function selectorContentField(
|
|
|
190
190
|
if (part.type === "tool_reference") {
|
|
191
191
|
return { type: "text", text: `Referenced tool: ${part.toolName}` };
|
|
192
192
|
}
|
|
193
|
+
// The selector model is text-only; it sees a marker, not the document bytes.
|
|
194
|
+
if (part.type === "document") return { type: "text", text: "(PDF document omitted from selector input)" };
|
|
193
195
|
return imagePart(part.url, part.detail);
|
|
194
196
|
}),
|
|
195
197
|
};
|
package/src/telemetry/report.ts
CHANGED
|
@@ -11,6 +11,7 @@ import {
|
|
|
11
11
|
type ChainHits,
|
|
12
12
|
type PrefixHit,
|
|
13
13
|
type PricingLookup,
|
|
14
|
+
type RoutingCandidate,
|
|
14
15
|
type RoutingDecision,
|
|
15
16
|
type RoutingStrategy,
|
|
16
17
|
} from "@mupt-ai/dari-router/policy-engine";
|
|
@@ -25,7 +26,7 @@ import {
|
|
|
25
26
|
type ProviderIdentity,
|
|
26
27
|
} from "../interaction/types.js";
|
|
27
28
|
import { captureOperationalError } from "../observability/errors.js";
|
|
28
|
-
import { gatewayPlatform, type RequestTags } from "../platform/platform.js";
|
|
29
|
+
import { gatewayPlatform, type RequestRecordRouter, type RequestTags } from "../platform/platform.js";
|
|
29
30
|
import type {
|
|
30
31
|
ProviderAttemptRecord,
|
|
31
32
|
ProviderAttemptReport,
|
|
@@ -179,6 +180,10 @@ export class RequestReporter {
|
|
|
179
180
|
identityHit?: PrefixHit;
|
|
180
181
|
hits: ChainHits;
|
|
181
182
|
costEstimates: CandidateCostEstimate[];
|
|
183
|
+
// The model and reasoning pairs this request could be routed to.
|
|
184
|
+
candidates: readonly RoutingCandidate[];
|
|
185
|
+
// The session pin this request routed under, when the router pins.
|
|
186
|
+
sessionPinKey?: string;
|
|
182
187
|
// Cache-aware selector-candidate pruning outcome, plus any pre-strategy
|
|
183
188
|
// capability exclusions. Null when custom routing configuration cannot
|
|
184
189
|
// produce candidates.
|
|
@@ -603,6 +608,9 @@ export class RequestReporter {
|
|
|
603
608
|
};
|
|
604
609
|
|
|
605
610
|
gatewayPlatform().recordRequest({
|
|
611
|
+
router: requestRecordRouter(a.credentials),
|
|
612
|
+
candidates: a.candidates.map(({ model, reasoningEffort }) => ({ model, reasoningEffort })),
|
|
613
|
+
...(a.sessionPinKey === undefined ? {} : { sessionPinKey: a.sessionPinKey }),
|
|
606
614
|
row: ingestPayload,
|
|
607
615
|
summary: {
|
|
608
616
|
request_id: a.requestId,
|
|
@@ -786,3 +794,28 @@ function usageIngest(
|
|
|
786
794
|
pricing_known: pricingKnown,
|
|
787
795
|
};
|
|
788
796
|
}
|
|
797
|
+
|
|
798
|
+
function requestRecordRouter(router: ResolvedRouter): RequestRecordRouter {
|
|
799
|
+
return {
|
|
800
|
+
enabledModels: [...router.enabled_models],
|
|
801
|
+
thinkingLevels: Object.fromEntries(
|
|
802
|
+
router.enabled_models.map((model) => [
|
|
803
|
+
model,
|
|
804
|
+
[
|
|
805
|
+
...(router.model_thinking_levels?.[model]
|
|
806
|
+
?? router.model_configs?.[model]?.supported_thinking_levels
|
|
807
|
+
?? []),
|
|
808
|
+
],
|
|
809
|
+
]),
|
|
810
|
+
),
|
|
811
|
+
defaultThinkingLevels: Object.fromEntries(
|
|
812
|
+
router.enabled_models.flatMap((model) => {
|
|
813
|
+
const level = router.model_configs?.[model]?.default_thinking_level;
|
|
814
|
+
return level ? [[model, level]] : [];
|
|
815
|
+
}),
|
|
816
|
+
),
|
|
817
|
+
strict: router.strict === true,
|
|
818
|
+
speculativeRouting: router.speculative_routing === true,
|
|
819
|
+
sessionPinning: router.session_pinning === true,
|
|
820
|
+
};
|
|
821
|
+
}
|