@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,54 @@
|
|
|
1
|
+
import { getBuiltinModels } from "@mupt-ai/pi-ai/providers/all";
|
|
2
|
+
const CHECK_TIMEOUT_MS = 10_000;
|
|
3
|
+
/**
|
|
4
|
+
* Ask a provider whether an API key works, with the cheapest authenticated
|
|
5
|
+
* request its wire API offers: list models, or, where listing models needs
|
|
6
|
+
* no key, an endpoint that does. The self-hosted gateway runs this at startup
|
|
7
|
+
* and the hosted control plane before it saves a key.
|
|
8
|
+
*/
|
|
9
|
+
export async function checkProviderKey(args) {
|
|
10
|
+
const check = keyCheckRequest(args.provider, args.key, args.endpoint);
|
|
11
|
+
if (check === null)
|
|
12
|
+
return { result: "unchecked" };
|
|
13
|
+
try {
|
|
14
|
+
const response = await args.fetch(check.url, {
|
|
15
|
+
headers: check.headers,
|
|
16
|
+
signal: AbortSignal.timeout(CHECK_TIMEOUT_MS),
|
|
17
|
+
});
|
|
18
|
+
await response.body?.cancel();
|
|
19
|
+
if (response.status === 401 || response.status === 403) {
|
|
20
|
+
return { result: "rejected", status: response.status };
|
|
21
|
+
}
|
|
22
|
+
return response.ok ? { result: "valid" } : { result: "unchecked" };
|
|
23
|
+
}
|
|
24
|
+
catch {
|
|
25
|
+
return { result: "unchecked" };
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
function keyCheckRequest(provider, key, endpoint) {
|
|
29
|
+
const bearer = { authorization: `Bearer ${key}` };
|
|
30
|
+
if (endpoint === undefined) {
|
|
31
|
+
if (provider === "openrouter")
|
|
32
|
+
return { url: "https://openrouter.ai/api/v1/key", headers: bearer };
|
|
33
|
+
if (provider === "vercel-ai-gateway")
|
|
34
|
+
return { url: "https://ai-gateway.vercel.sh/v1/credits", headers: bearer };
|
|
35
|
+
}
|
|
36
|
+
const target = endpoint
|
|
37
|
+
?? getBuiltinModels(provider)?.[0];
|
|
38
|
+
if (target === undefined || !URL.canParse(target.baseUrl))
|
|
39
|
+
return null;
|
|
40
|
+
const base = target.baseUrl.replace(/\/+$/, "");
|
|
41
|
+
switch (target.api) {
|
|
42
|
+
case "anthropic-messages":
|
|
43
|
+
return { url: `${base}/v1/models`, headers: { "x-api-key": key, "anthropic-version": "2023-06-01" } };
|
|
44
|
+
case "google-generative-ai":
|
|
45
|
+
return { url: `${base}/models`, headers: { "x-goog-api-key": key } };
|
|
46
|
+
case "mistral-conversations":
|
|
47
|
+
return { url: `${base}/v1/models`, headers: bearer };
|
|
48
|
+
case "openai-completions":
|
|
49
|
+
case "openai-responses":
|
|
50
|
+
return { url: `${base}/models`, headers: bearer };
|
|
51
|
+
default:
|
|
52
|
+
return null;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
@@ -4,6 +4,8 @@ type ModelCapabilityMetadata = {
|
|
|
4
4
|
export declare const BEDROCK_API = "bedrock-converse-stream";
|
|
5
5
|
export declare function isBedrockApi(api: unknown): boolean;
|
|
6
6
|
export declare function modelSupportsImageInput(metadata: ModelCapabilityMetadata): boolean;
|
|
7
|
+
export declare function apiSupportsDocumentInput(api: string): boolean;
|
|
8
|
+
export declare function modelSupportsDocumentInput(modelId: string, metadata: ModelCapabilityMetadata): boolean;
|
|
7
9
|
export declare function providerApiCanSerializeResponseFormat(metadata: ModelCapabilityMetadata): boolean;
|
|
8
10
|
export declare function isOpenAIResponsesApi(api: unknown): boolean;
|
|
9
11
|
export declare function modelSupportsReasoningConfigurationUpdate(metadata: ModelCapabilityMetadata): boolean;
|
|
@@ -6,6 +6,21 @@ export function isBedrockApi(api) {
|
|
|
6
6
|
export function modelSupportsImageInput(metadata) {
|
|
7
7
|
return Array.isArray(metadata.model.input) && metadata.model.input.includes("image");
|
|
8
8
|
}
|
|
9
|
+
/* PDFs are sent natively only where the model owner documents PDF input on
|
|
10
|
+
that API. The same API can serve other owners (xAI and Meta on Responses,
|
|
11
|
+
GPT on Bedrock) whose PDF support is unverified, so both must match. */
|
|
12
|
+
const DOCUMENT_INPUT_APIS_BY_OWNER = {
|
|
13
|
+
anthropic: new Set(["anthropic-messages", "bedrock-converse-stream"]),
|
|
14
|
+
openai: new Set(["openai-responses", "azure-openai-responses", "openai-codex-responses"]),
|
|
15
|
+
};
|
|
16
|
+
export function apiSupportsDocumentInput(api) {
|
|
17
|
+
return Object.values(DOCUMENT_INPUT_APIS_BY_OWNER).some((apis) => apis.has(api));
|
|
18
|
+
}
|
|
19
|
+
export function modelSupportsDocumentInput(modelId, metadata) {
|
|
20
|
+
const owner = modelId.split("/", 1)[0] ?? "";
|
|
21
|
+
const api = metadata.model.api;
|
|
22
|
+
return typeof api === "string" && DOCUMENT_INPUT_APIS_BY_OWNER[owner]?.has(api) === true;
|
|
23
|
+
}
|
|
9
24
|
export function providerApiCanSerializeResponseFormat(metadata) {
|
|
10
25
|
return (metadata.model.api === "openai-responses" ||
|
|
11
26
|
metadata.model.api === "azure-openai-responses" ||
|
|
@@ -5,6 +5,7 @@ import { parseDataImageUrl } from "../interaction/image.js";
|
|
|
5
5
|
import { providerToolCallId, toolWireName } from "../interaction/tool_identity.js";
|
|
6
6
|
import { compatibleProviderContinuation, sameProviderIdentity, } from "../interaction/provider_continuation_state.js";
|
|
7
7
|
import { isOpenAIResponsesApi } from "./model_capabilities.js";
|
|
8
|
+
import { apiSupportsDocumentInput } from "./model_capabilities.js";
|
|
8
9
|
const UNKNOWN_SOURCE = {
|
|
9
10
|
provider: "dari",
|
|
10
11
|
api: "interaction-history",
|
|
@@ -72,7 +73,7 @@ export function buildContext(request, target) {
|
|
|
72
73
|
sawConversationItem = true;
|
|
73
74
|
if (item.type === "message" && item.role === "user") {
|
|
74
75
|
flushAssistant();
|
|
75
|
-
appendUserMessage(messages, userContent(item.content, item.contentShape), timestamp);
|
|
76
|
+
appendUserMessage(messages, userContent(item.content, item.contentShape, target), timestamp);
|
|
76
77
|
continue;
|
|
77
78
|
}
|
|
78
79
|
if (item.type === "tool_result") {
|
|
@@ -252,10 +253,10 @@ function thinkingContent(item, continuation) {
|
|
|
252
253
|
redacted: true,
|
|
253
254
|
};
|
|
254
255
|
}
|
|
255
|
-
function userContent(content, shape) {
|
|
256
|
+
function userContent(content, shape, target) {
|
|
256
257
|
if (shape !== "parts")
|
|
257
258
|
return content.filter((part) => part.type === "text").map((part) => part.text).join("");
|
|
258
|
-
return content.map(contentToPi);
|
|
259
|
+
return content.map((part) => contentToPi(part, target));
|
|
259
260
|
}
|
|
260
261
|
function appendPositionalNote(messages, text, timestamp) {
|
|
261
262
|
const previous = messages.at(-1);
|
|
@@ -290,13 +291,20 @@ function toolResultContent(content, target) {
|
|
|
290
291
|
const filtered = target.api === "anthropic-messages"
|
|
291
292
|
? content.filter((part) => part.type !== "tool_reference")
|
|
292
293
|
: content;
|
|
293
|
-
return filtered.map(contentToPi);
|
|
294
|
+
return filtered.map((part) => contentToPi(part, target));
|
|
294
295
|
}
|
|
295
|
-
function contentToPi(content) {
|
|
296
|
+
function contentToPi(content, target) {
|
|
296
297
|
if (content.type === "text")
|
|
297
298
|
return { type: "text", text: content.text };
|
|
298
299
|
if (content.type === "tool_reference")
|
|
299
300
|
return { type: "text", text: `Referenced tool: ${content.toolName}` };
|
|
301
|
+
if (content.type === "document") {
|
|
302
|
+
// Routing keeps PDFs on document-capable models; this guards direct callers.
|
|
303
|
+
if (!apiSupportsDocumentInput(target.api)) {
|
|
304
|
+
throw new HttpError(400, `PDF document input is not supported by the ${target.api} API.`, "unsupported_input_modality", "input.document");
|
|
305
|
+
}
|
|
306
|
+
return { type: "document", mimeType: content.mimeType, data: content.data };
|
|
307
|
+
}
|
|
300
308
|
const image = parseDataImageUrl(content.url);
|
|
301
309
|
if (!image) {
|
|
302
310
|
throw new HttpError(400, "Only base64 data URLs are supported for routed image input.", "invalid_request_error", "input.image_url");
|
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import { interactionConversationPrefix } from "../context/interaction_fingerprint.js";
|
|
2
|
+
import { clampRequestedEffort } from "../context/routing_context.js";
|
|
3
|
+
import { modelMetadataLookup } from "../providers/pi.js";
|
|
2
4
|
import { buildRoutingContext, derivePrefixLookupHashes, } from "../context/routing_context.js";
|
|
3
5
|
import { isBackgroundRequest } from "../interaction/types.js";
|
|
4
6
|
import { executionProviderForModel } from "../models/execution_provider.js";
|
|
@@ -84,7 +86,10 @@ export async function prepareRoutedCompletion(args) {
|
|
|
84
86
|
}
|
|
85
87
|
const access = strictProviderAccess(credentials, args.interaction);
|
|
86
88
|
credentials = access.credentials;
|
|
87
|
-
args = {
|
|
89
|
+
args = {
|
|
90
|
+
...args,
|
|
91
|
+
interaction: clampRequestedEffort(access.interaction, credentials, modelMetadataLookup(credentials.model_configs)),
|
|
92
|
+
};
|
|
88
93
|
credentials = await withProviderCredentials(credentials, args.apiKey, args.clientHarness);
|
|
89
94
|
const billable = true;
|
|
90
95
|
const routing = await buildRoutingContext({
|
|
@@ -138,6 +143,8 @@ export async function prepareRoutedCompletion(args) {
|
|
|
138
143
|
identityHit: routing.identityHit,
|
|
139
144
|
hits: routing.hits,
|
|
140
145
|
costEstimates: routing.reportingCostEstimates,
|
|
146
|
+
candidates: candidateResolution.candidates,
|
|
147
|
+
sessionPinKey: sessionPin?.key,
|
|
141
148
|
pruning: reportedPruning(candidateResolution.pruning, routing.imageCapabilityPrunedModels),
|
|
142
149
|
pricing: routing.pricing,
|
|
143
150
|
chainByModel: routing.prefix.chainByModel,
|
|
@@ -7,7 +7,7 @@ import { type ReasoningEffort } from "@mupt-ai/dari-router/policy-engine";
|
|
|
7
7
|
* default:
|
|
8
8
|
* models:
|
|
9
9
|
* - anthropic/claude-sonnet-5-5
|
|
10
|
-
* - id: openai/gpt-
|
|
10
|
+
* - id: openai/gpt-6.1-sol
|
|
11
11
|
* thinking: [medium, high]
|
|
12
12
|
* subscriptions: true # serve with connected Claude Code / ChatGPT plans
|
|
13
13
|
* sessionPinning: false # route each session and subagent once, then keep its model
|
package/dist/self_host/main.js
CHANGED
|
@@ -8,9 +8,11 @@ import { createRequestHandler } from "../http/handler.js";
|
|
|
8
8
|
import { installShutdownHandlers, startServer } from "../http/serve.js";
|
|
9
9
|
import { createAutoRoutingSelectorBackend } from "../strategies/auto_routing_selector.js";
|
|
10
10
|
import { createPrioritySelectorBackend } from "../strategies/priority_selector.js";
|
|
11
|
-
import { loadGatewayConfig } from "./config.js";
|
|
12
11
|
import { createSelfHostedPlatform } from "./platform.js";
|
|
13
|
-
import {
|
|
12
|
+
import { providerForModel } from "@mupt-ai/dari-router/policy-engine";
|
|
13
|
+
import { checkProviderKey } from "../providers/key_check.js";
|
|
14
|
+
import { loadGatewayConfig } from "./config.js";
|
|
15
|
+
import { configuredProviderApiKey, gatewayKeyIndex, providersWithoutCredentials } from "./resolve.js";
|
|
14
16
|
import { PrefixStore } from "./prefix_store.js";
|
|
15
17
|
import { SessionPinStore } from "./session_pin_store.js";
|
|
16
18
|
import { handleSubscriptionRequest, SubscriptionStore } from "./subscriptions/index.js";
|
|
@@ -69,6 +71,7 @@ export async function startSelfHostedGateway(env = process.env) {
|
|
|
69
71
|
console.warn("dari-gateway router has providers without credentials; their models are not served", JSON.stringify({ router: routerId, providers: missing }));
|
|
70
72
|
}
|
|
71
73
|
}
|
|
74
|
+
await checkConfiguredProviderKeys(config, (provider) => configuredProviderApiKey({ config, providerApiKey, env }, provider));
|
|
72
75
|
const autoRoutingKey = env.DARI_AUTO_ROUTING_API_KEY?.trim();
|
|
73
76
|
const selectorBackend = autoRoutingKey
|
|
74
77
|
? createAutoRoutingSelectorBackend({
|
|
@@ -99,12 +102,38 @@ export async function startSelfHostedGateway(env = process.env) {
|
|
|
99
102
|
const server = startServer(port, handler, { hostname });
|
|
100
103
|
console.info("dari-gateway listening", JSON.stringify({
|
|
101
104
|
hostname,
|
|
102
|
-
port,
|
|
105
|
+
// The bound port, so PORT=0 reports the port the OS assigned.
|
|
106
|
+
port: server.port,
|
|
103
107
|
routers: Object.keys(config.routers),
|
|
104
108
|
auto_routing: autoRoutingKey ? "dari" : "priority",
|
|
105
109
|
}));
|
|
106
110
|
return { server, close: () => db.close() };
|
|
107
111
|
}
|
|
112
|
+
/**
|
|
113
|
+
* Check each provider API key the routers use before serving: a key its
|
|
114
|
+
* provider rejects fails startup, and one no check could answer is reported.
|
|
115
|
+
*/
|
|
116
|
+
async function checkConfiguredProviderKeys(config, providerApiKey) {
|
|
117
|
+
const providers = new Set(Object.values(config.routers).flatMap((router) => router.models.map((model) => providerForModel(model.id))));
|
|
118
|
+
const checks = await Promise.all([...providers].map(async (provider) => {
|
|
119
|
+
const endpoint = config.providers[provider];
|
|
120
|
+
// Endpoints configured without a key variable take no key.
|
|
121
|
+
if (endpoint !== undefined && endpoint.apiKeyEnv === undefined)
|
|
122
|
+
return null;
|
|
123
|
+
const key = await providerApiKey(provider);
|
|
124
|
+
if (key === undefined)
|
|
125
|
+
return null;
|
|
126
|
+
return { provider, ...(await checkProviderKey({ provider, key, ...(endpoint ? { endpoint } : {}), fetch })) };
|
|
127
|
+
}));
|
|
128
|
+
const rejected = checks.flatMap((check) => check?.result === "rejected" ? [`${check.provider} (${check.status})`] : []);
|
|
129
|
+
if (rejected.length > 0) {
|
|
130
|
+
throw new Error(`Provider API keys were rejected: ${rejected.join(", ")}. Fix or unset each key before starting the gateway.`);
|
|
131
|
+
}
|
|
132
|
+
const unchecked = checks.flatMap((check) => check?.result === "unchecked" ? [check.provider] : []);
|
|
133
|
+
if (unchecked.length > 0) {
|
|
134
|
+
console.warn("dari-gateway could not check these provider API keys", JSON.stringify({ providers: unchecked }));
|
|
135
|
+
}
|
|
136
|
+
}
|
|
108
137
|
if (import.meta.main) {
|
|
109
138
|
// Startup errors are configuration mistakes: print the message, not a stack.
|
|
110
139
|
const started = await startSelfHostedGateway().catch((error) => {
|
|
@@ -25,6 +25,7 @@ export type SelfHostedResolverDeps = {
|
|
|
25
25
|
* neither are left out.
|
|
26
26
|
*/
|
|
27
27
|
export declare function resolveSelfHostedRouter(deps: SelfHostedResolverDeps, request: ResolveRouterRequest): Promise<ResolvedRouter>;
|
|
28
|
+
export declare function configuredProviderApiKey(deps: Pick<SelfHostedResolverDeps, "config" | "providerApiKey" | "env">, provider: string): Promise<string | undefined>;
|
|
28
29
|
export declare function gatewayKeyIndex(keys: readonly string[], presented: string): number | null;
|
|
29
30
|
/**
|
|
30
31
|
* Providers a router lists that no credential can serve: no API key and no
|
|
@@ -44,7 +44,7 @@ export async function resolveSelfHostedRouter(deps, request) {
|
|
|
44
44
|
if (subscription !== null && deps.subscriptions.list(subscription).length > 0) {
|
|
45
45
|
exhausted.add(subscription);
|
|
46
46
|
}
|
|
47
|
-
const apiKey = await
|
|
47
|
+
const apiKey = await configuredProviderApiKey(deps, provider);
|
|
48
48
|
credentials.set(provider, apiKey ? { key: apiKey, source: "user" } : null);
|
|
49
49
|
}
|
|
50
50
|
const models = router.models.filter((model) => credentials.get(providerForModel(model.id)) != null);
|
|
@@ -112,7 +112,7 @@ export async function resolveSelfHostedRouter(deps, request) {
|
|
|
112
112
|
}
|
|
113
113
|
// Configured endpoints read their own key variable, or send a placeholder
|
|
114
114
|
// when they take none; known providers use their standard variable.
|
|
115
|
-
async function
|
|
115
|
+
export async function configuredProviderApiKey(deps, provider) {
|
|
116
116
|
const endpoint = deps.config.providers[provider];
|
|
117
117
|
if (endpoint === undefined)
|
|
118
118
|
return (await deps.providerApiKey(provider))?.trim() || undefined;
|
|
@@ -172,7 +172,7 @@ export async function providersWithoutCredentials(deps, router) {
|
|
|
172
172
|
const connected = plans.some((plan) => plan !== null && deps.subscriptions.list(plan).length > 0);
|
|
173
173
|
if (connected)
|
|
174
174
|
continue;
|
|
175
|
-
const apiKey = await
|
|
175
|
+
const apiKey = await configuredProviderApiKey(deps, provider);
|
|
176
176
|
if (apiKey === undefined)
|
|
177
177
|
missing.push(provider);
|
|
178
178
|
}
|
|
@@ -145,6 +145,9 @@ function selectorContentField(content, shape, imagePart) {
|
|
|
145
145
|
if (part.type === "tool_reference") {
|
|
146
146
|
return { type: "text", text: `Referenced tool: ${part.toolName}` };
|
|
147
147
|
}
|
|
148
|
+
// The selector model is text-only; it sees a marker, not the document bytes.
|
|
149
|
+
if (part.type === "document")
|
|
150
|
+
return { type: "text", text: "(PDF document omitted from selector input)" };
|
|
148
151
|
return imagePart(part.url, part.detail);
|
|
149
152
|
}),
|
|
150
153
|
};
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { type CandidateCostEstimate, type CandidatePruningAudit, type ChainHits, type PrefixHit, type PricingLookup, type RoutingDecision, type RoutingStrategy } from "@mupt-ai/dari-router/policy-engine";
|
|
1
|
+
import { type CandidateCostEstimate, type CandidatePruningAudit, type ChainHits, type PrefixHit, type PricingLookup, type RoutingCandidate, type RoutingDecision, type RoutingStrategy } from "@mupt-ai/dari-router/policy-engine";
|
|
2
2
|
import type { ImageCapabilityPrunedModel } from "../context/routing_context.js";
|
|
3
3
|
import { type InteractionRequest, type ProviderIdentity } from "../interaction/types.js";
|
|
4
4
|
import { type RequestTags } from "../platform/platform.js";
|
|
@@ -47,6 +47,8 @@ export declare class RequestReporter {
|
|
|
47
47
|
identityHit?: PrefixHit;
|
|
48
48
|
hits: ChainHits;
|
|
49
49
|
costEstimates: CandidateCostEstimate[];
|
|
50
|
+
candidates: readonly RoutingCandidate[];
|
|
51
|
+
sessionPinKey?: string;
|
|
50
52
|
pruning: ReportedCandidatePruning | null;
|
|
51
53
|
pricing: PricingLookup | null;
|
|
52
54
|
chainByModel: ReadonlyMap<string, string[]>;
|
package/dist/telemetry/report.js
CHANGED
|
@@ -414,6 +414,9 @@ export class RequestReporter {
|
|
|
414
414
|
touched_prefix_hash: touchedHash,
|
|
415
415
|
};
|
|
416
416
|
gatewayPlatform().recordRequest({
|
|
417
|
+
router: requestRecordRouter(a.credentials),
|
|
418
|
+
candidates: a.candidates.map(({ model, reasoningEffort }) => ({ model, reasoningEffort })),
|
|
419
|
+
...(a.sessionPinKey === undefined ? {} : { sessionPinKey: a.sessionPinKey }),
|
|
417
420
|
row: ingestPayload,
|
|
418
421
|
summary: {
|
|
419
422
|
request_id: a.requestId,
|
|
@@ -583,3 +586,23 @@ function usageIngest(usage, pricingKnown) {
|
|
|
583
586
|
pricing_known: pricingKnown,
|
|
584
587
|
};
|
|
585
588
|
}
|
|
589
|
+
function requestRecordRouter(router) {
|
|
590
|
+
return {
|
|
591
|
+
enabledModels: [...router.enabled_models],
|
|
592
|
+
thinkingLevels: Object.fromEntries(router.enabled_models.map((model) => [
|
|
593
|
+
model,
|
|
594
|
+
[
|
|
595
|
+
...(router.model_thinking_levels?.[model]
|
|
596
|
+
?? router.model_configs?.[model]?.supported_thinking_levels
|
|
597
|
+
?? []),
|
|
598
|
+
],
|
|
599
|
+
])),
|
|
600
|
+
defaultThinkingLevels: Object.fromEntries(router.enabled_models.flatMap((model) => {
|
|
601
|
+
const level = router.model_configs?.[model]?.default_thinking_level;
|
|
602
|
+
return level ? [[model, level]] : [];
|
|
603
|
+
})),
|
|
604
|
+
strict: router.strict === true,
|
|
605
|
+
speculativeRouting: router.speculative_routing === true,
|
|
606
|
+
sessionPinning: router.session_pinning === true,
|
|
607
|
+
};
|
|
608
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mupt-ai/dari-gateway",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.4",
|
|
4
4
|
"description": "Self-hostable LLM gateway for coding agents and APIs: one endpoint for Anthropic Messages, OpenAI Chat Completions, and OpenAI Responses, with subscription pooling, failover, and prompt-cache-aware routing.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"repository": {
|
|
@@ -50,7 +50,7 @@
|
|
|
50
50
|
"dependencies": {
|
|
51
51
|
"@aws-sdk/client-sts": "3.1063.0",
|
|
52
52
|
"@mupt-ai/dari-router": "0.2.5",
|
|
53
|
-
"@mupt-ai/pi-ai": "1.4.0-mupt.
|
|
53
|
+
"@mupt-ai/pi-ai": "1.4.0-mupt.3"
|
|
54
54
|
},
|
|
55
55
|
"devDependencies": {
|
|
56
56
|
"@types/bun": "^1.3.4",
|
|
@@ -22,6 +22,7 @@ export type FingerprintContent =
|
|
|
22
22
|
url: string;
|
|
23
23
|
detail?: "auto" | "low" | "high" | "original";
|
|
24
24
|
}
|
|
25
|
+
| { type: "document"; mimeType: "application/pdf"; data: string }
|
|
25
26
|
| { type: "tool_reference"; toolName: string }
|
|
26
27
|
>
|
|
27
28
|
| null;
|
|
@@ -150,6 +151,9 @@ export function interactionFingerprintContent(
|
|
|
150
151
|
toolName: part.toolName,
|
|
151
152
|
};
|
|
152
153
|
}
|
|
154
|
+
if (part.type === "document") {
|
|
155
|
+
return { type: "document" as const, mimeType: part.mimeType, data: part.data };
|
|
156
|
+
}
|
|
153
157
|
return {
|
|
154
158
|
type: "image" as const,
|
|
155
159
|
url: part.url,
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { constants as zlibConstants, inflateSync } from "node:zlib";
|
|
2
|
+
|
|
1
3
|
import {
|
|
2
4
|
isRecord,
|
|
3
5
|
stableStringify,
|
|
@@ -27,6 +29,42 @@ const IMAGE_ESTIMATED_TOKENS = {
|
|
|
27
29
|
original: 2048,
|
|
28
30
|
} as const;
|
|
29
31
|
const CHARS_PER_ESTIMATED_TOKEN = 4;
|
|
32
|
+
// Anthropic bills each PDF page as its text plus a page image: 1,500-3,000
|
|
33
|
+
// tokens. Use the top of that range so a long PDF cannot look like it fits.
|
|
34
|
+
const PDF_ESTIMATED_TOKENS_PER_PAGE = 3000;
|
|
35
|
+
const PDF_PAGE_PATTERN = /\/Type\s*\/Page(?![a-zA-Z])/g;
|
|
36
|
+
const PDF_OBJECT_STREAM_START = /\/Type\s*\/ObjStm[\s\S]*?stream\r?\n/g;
|
|
37
|
+
// Object streams hold small dictionaries. The cap is total inflated bytes per
|
|
38
|
+
// PDF, so a compression bomb in a request costs at most this much memory.
|
|
39
|
+
const PDF_OBJECT_STREAM_INFLATE_BUDGET = 4 * 1024 * 1024;
|
|
40
|
+
|
|
41
|
+
function estimatedPdfPages(base64: string): number {
|
|
42
|
+
const bytes = Buffer.from(base64, "base64");
|
|
43
|
+
// latin1 maps each byte to one character, so string offsets are byte offsets.
|
|
44
|
+
const pdf = bytes.toString("latin1");
|
|
45
|
+
const visible = pdf.match(PDF_PAGE_PATTERN)?.length ?? 0;
|
|
46
|
+
if (visible > 0) return visible;
|
|
47
|
+
// PDF 1.5+ may keep every page dictionary in compressed object streams.
|
|
48
|
+
let hidden = 0;
|
|
49
|
+
let budget = PDF_OBJECT_STREAM_INFLATE_BUDGET;
|
|
50
|
+
for (const match of pdf.matchAll(PDF_OBJECT_STREAM_START)) {
|
|
51
|
+
const start = match.index + match[0].length;
|
|
52
|
+
const end = pdf.indexOf("endstream", start);
|
|
53
|
+
if (end === -1 || budget <= 0) break;
|
|
54
|
+
try {
|
|
55
|
+
const objects = inflateSync(bytes.subarray(start, end), {
|
|
56
|
+
finishFlush: zlibConstants.Z_SYNC_FLUSH,
|
|
57
|
+
maxOutputLength: budget,
|
|
58
|
+
});
|
|
59
|
+
budget -= objects.length;
|
|
60
|
+
hidden += objects.toString("latin1").match(PDF_PAGE_PATTERN)?.length ?? 0;
|
|
61
|
+
} catch (error) {
|
|
62
|
+
// Over budget: stop. Another filter or bad data: count what is readable.
|
|
63
|
+
if (error instanceof RangeError) break;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
return Math.max(1, hidden);
|
|
67
|
+
}
|
|
30
68
|
|
|
31
69
|
// Cost and cache-lookback estimates are candidate-specific because compatible
|
|
32
70
|
// continuation state and provider serialization can change what each model
|
|
@@ -130,7 +168,7 @@ export function promptMetrics(
|
|
|
130
168
|
0,
|
|
131
169
|
),
|
|
132
170
|
serializedChars:
|
|
133
|
-
stableStringify(prompt).length + accountingInput.
|
|
171
|
+
stableStringify(prompt).length + accountingInput.attachmentEstimatedChars,
|
|
134
172
|
};
|
|
135
173
|
}
|
|
136
174
|
|
|
@@ -214,14 +252,22 @@ function providerVisibleTools(
|
|
|
214
252
|
|
|
215
253
|
function promptAccountingInput(request: InteractionRequest): {
|
|
216
254
|
request: InteractionRequest;
|
|
217
|
-
|
|
255
|
+
attachmentEstimatedChars: number;
|
|
218
256
|
} {
|
|
219
|
-
let
|
|
220
|
-
|
|
257
|
+
let attachmentEstimatedChars = 0;
|
|
258
|
+
// Attachments are estimated rather than serialized: their base64 is not
|
|
259
|
+
// what the provider bills, and a PDF must not reach the serializer of a
|
|
260
|
+
// model that cannot carry it.
|
|
261
|
+
const sanitizeContent = (parts: InteractionContent[]): InteractionContent[] =>
|
|
221
262
|
parts.map((part) => {
|
|
263
|
+
if (part.type === "document") {
|
|
264
|
+
attachmentEstimatedChars +=
|
|
265
|
+
estimatedPdfPages(part.data) * PDF_ESTIMATED_TOKENS_PER_PAGE * CHARS_PER_ESTIMATED_TOKEN;
|
|
266
|
+
return { type: "text", text: "" };
|
|
267
|
+
}
|
|
222
268
|
if (part.type !== "image") return part;
|
|
223
269
|
const detail = part.detail ?? "auto";
|
|
224
|
-
|
|
270
|
+
attachmentEstimatedChars +=
|
|
225
271
|
IMAGE_ESTIMATED_TOKENS[detail] * CHARS_PER_ESTIMATED_TOKEN;
|
|
226
272
|
return { ...part, url: ACCOUNTING_IMAGE_URL };
|
|
227
273
|
});
|
|
@@ -236,6 +282,6 @@ function promptAccountingInput(request: InteractionRequest): {
|
|
|
236
282
|
});
|
|
237
283
|
return {
|
|
238
284
|
request: { ...request, items },
|
|
239
|
-
|
|
285
|
+
attachmentEstimatedChars,
|
|
240
286
|
};
|
|
241
287
|
}
|
|
@@ -10,6 +10,8 @@ import {
|
|
|
10
10
|
type HardCapability,
|
|
11
11
|
type PreviousDecision,
|
|
12
12
|
type PricingLookup,
|
|
13
|
+
isReasoningEffort,
|
|
14
|
+
type ReasoningEffort,
|
|
13
15
|
type SelectorLeaseHistoryEntry,
|
|
14
16
|
type StrategyCandidateResolution,
|
|
15
17
|
} from "@mupt-ai/dari-router/policy-engine";
|
|
@@ -18,12 +20,14 @@ import { randomUUID } from "node:crypto";
|
|
|
18
20
|
import { HttpError } from "../http/errors.js";
|
|
19
21
|
import { effectiveContextWindow } from "../models/context_limits.js";
|
|
20
22
|
import { keySourceForModel } from "../models/execution_provider.js";
|
|
21
|
-
import { requiredCapabilitiesFor } from "../interaction/capabilities.js";
|
|
23
|
+
import { requestHasDocument, requiredCapabilitiesFor } from "../interaction/capabilities.js";
|
|
22
24
|
import { providerIdentityFromModel } from "../interaction/provider_continuation_state.js";
|
|
23
25
|
import { isBackgroundRequest, type InteractionRequest, type ProviderIdentity } from "../interaction/types.js";
|
|
24
26
|
import { requireCandidateModels } from "../models/ids.js";
|
|
27
|
+
import { explicitModelId } from "../protocol/input/router_model.js";
|
|
25
28
|
import { modelMetadataLookup } from "../providers/pi.js";
|
|
26
29
|
import {
|
|
30
|
+
modelSupportsDocumentInput,
|
|
27
31
|
modelSupportsImageInput,
|
|
28
32
|
modelSupportsHostedWebSearch,
|
|
29
33
|
modelSupportsReasoningConfigurationUpdate,
|
|
@@ -152,6 +156,11 @@ export async function buildRoutingContext(args: {
|
|
|
152
156
|
enabled: args.credentials.image_capability_filtering !== false,
|
|
153
157
|
supportsImageInput: (model) => capabilityLookup(model).supportsImageInput,
|
|
154
158
|
});
|
|
159
|
+
const documentCapableModels = documentCapableCandidates(
|
|
160
|
+
imageFilter.candidateModels,
|
|
161
|
+
requestHasDocument(args.interaction),
|
|
162
|
+
(model) => modelSupportsDocumentInput(model, metadataLookup(model)),
|
|
163
|
+
);
|
|
155
164
|
const prefix = derivePrefixContext({
|
|
156
165
|
routerId: args.routerId,
|
|
157
166
|
interaction: args.interaction,
|
|
@@ -171,7 +180,7 @@ export async function buildRoutingContext(args: {
|
|
|
171
180
|
|| hit.conversation_id === args.explicitConversationId,
|
|
172
181
|
);
|
|
173
182
|
const prepared = prepareCandidates({
|
|
174
|
-
candidateModels:
|
|
183
|
+
candidateModels: documentCapableModels,
|
|
175
184
|
metadataLookup: capabilityLookup,
|
|
176
185
|
requiredCapabilities: imageFilter.requiredCapabilities,
|
|
177
186
|
requestedReasoningEffort: args.interaction.reasoning?.effort,
|
|
@@ -308,6 +317,25 @@ function resolveImageCapableCandidates(args: {
|
|
|
308
317
|
};
|
|
309
318
|
}
|
|
310
319
|
|
|
320
|
+
// A PDF can only be sent natively, so a conversation carrying one must not
|
|
321
|
+
// route (or hold a lease) to a model that cannot read it.
|
|
322
|
+
function documentCapableCandidates(
|
|
323
|
+
candidateModels: string[],
|
|
324
|
+
hasDocument: boolean,
|
|
325
|
+
supportsDocumentInput: (model: string) => boolean,
|
|
326
|
+
): string[] {
|
|
327
|
+
if (!hasDocument) return candidateModels;
|
|
328
|
+
const capable = candidateModels.filter(supportsDocumentInput);
|
|
329
|
+
if (capable.length === 0) {
|
|
330
|
+
throw new HttpError(
|
|
331
|
+
400,
|
|
332
|
+
"This conversation contains a PDF document, but no enabled model on this router accepts PDF input.",
|
|
333
|
+
"no_document_capable_candidates",
|
|
334
|
+
);
|
|
335
|
+
}
|
|
336
|
+
return capable;
|
|
337
|
+
}
|
|
338
|
+
|
|
311
339
|
function derivePrefixContext(args: {
|
|
312
340
|
routerId: string;
|
|
313
341
|
interaction: InteractionRequest;
|
|
@@ -335,3 +363,29 @@ function derivePrefixContext(args: {
|
|
|
335
363
|
};
|
|
336
364
|
}
|
|
337
365
|
}
|
|
366
|
+
|
|
367
|
+
// An explicit `dari/model/<id>` serves the requested thinking level when the
|
|
368
|
+
// model offers it and otherwise its default level, so the request itself drops
|
|
369
|
+
// an unsupported level before routing and provider validation see it. A strict
|
|
370
|
+
// router never substitutes, so it keeps the level and fails on it.
|
|
371
|
+
export function clampRequestedEffort(
|
|
372
|
+
interaction: InteractionRequest,
|
|
373
|
+
credentials: { strict?: boolean; model_thinking_levels?: Record<string, ReasoningEffort[]> },
|
|
374
|
+
metadataLookup: ReturnType<typeof modelMetadataLookup>,
|
|
375
|
+
): InteractionRequest {
|
|
376
|
+
const requested = interaction.reasoning?.effort;
|
|
377
|
+
const modelId = explicitModelId(interaction.model);
|
|
378
|
+
if (requested === undefined || modelId === null || credentials.strict === true) return interaction;
|
|
379
|
+
const supported = metadataLookup(modelId).supportedThinkingLevels.filter(isReasoningEffort);
|
|
380
|
+
const configured = credentials.model_thinking_levels?.[modelId] ?? supported;
|
|
381
|
+
if (configured.includes(requested) && supported.includes(requested)) return interaction;
|
|
382
|
+
// The effort implied thinking on and its effort-derived fields (baseEffort,
|
|
383
|
+
// configuration updates) must not leak into the default. Keep only an explicit
|
|
384
|
+
// thinking-off and any thinking budget.
|
|
385
|
+
const { enabled, budgetTokens } = interaction.reasoning!;
|
|
386
|
+
const reasoning = {
|
|
387
|
+
...(enabled === false ? { enabled } : {}),
|
|
388
|
+
...(budgetTokens === undefined ? {} : { budgetTokens }),
|
|
389
|
+
};
|
|
390
|
+
return { ...interaction, reasoning: Object.keys(reasoning).length === 0 ? undefined : reasoning };
|
|
391
|
+
}
|
|
@@ -5,6 +5,7 @@ import { anthropicToken } from "../http/auth.js";
|
|
|
5
5
|
import { errorResponseHeaders, normalizeError } from "../http/errors.js";
|
|
6
6
|
import { captureRequestError } from "../observability/errors.js";
|
|
7
7
|
import { anthropicMessagesToInteraction } from "../protocol/input/anthropic_messages.js";
|
|
8
|
+
import { explicitModelId } from "../protocol/input/router_model.js";
|
|
8
9
|
import { handleRoutedCompletion } from "../runtime/routed_completion.js";
|
|
9
10
|
import type { SelectorBackend } from "../strategies/routing.js";
|
|
10
11
|
import { claudeCodeRequestContext } from "./claude_code/request_context.js";
|
|
@@ -12,7 +13,7 @@ import {
|
|
|
12
13
|
claudeCodeConversationIdentity,
|
|
13
14
|
} from "../runtime/harness_identity/index.js";
|
|
14
15
|
import { parseJsonRequest } from "./json.js";
|
|
15
|
-
import type { AnthropicSafeguards } from "../interaction/types.js";
|
|
16
|
+
import type { AnthropicSafeguards, ReasoningControls } from "../interaction/types.js";
|
|
16
17
|
|
|
17
18
|
type AnthropicErrorType =
|
|
18
19
|
| "authentication_error"
|
|
@@ -42,6 +43,7 @@ export async function handleAnthropicMessages(
|
|
|
42
43
|
const payload = isClaudeCode
|
|
43
44
|
? {
|
|
44
45
|
...forwarded,
|
|
46
|
+
reasoning: routedReasoning(forwarded),
|
|
45
47
|
agentContext: claudeCodeRequestContext(request.headers),
|
|
46
48
|
// Native tool results may be followed by harness-injected text.
|
|
47
49
|
// Preserve their structural continuation semantics, not prompt inference.
|
|
@@ -79,6 +81,18 @@ export async function handleAnthropicMessages(
|
|
|
79
81
|
}
|
|
80
82
|
}
|
|
81
83
|
|
|
84
|
+
// Claude Code sends its session effort on every turn. That preference
|
|
85
|
+
// describes a direct model, not a heterogeneous router, so let Dari select the
|
|
86
|
+
// effort when the router routes; a `dari/model/<id>` keeps the chosen effort.
|
|
87
|
+
function routedReasoning(
|
|
88
|
+
interaction: ReturnType<typeof anthropicMessagesToInteraction>,
|
|
89
|
+
): ReasoningControls | undefined {
|
|
90
|
+
const reasoning = interaction.reasoning;
|
|
91
|
+
if (reasoning === undefined || explicitModelId(interaction.model) !== null) return reasoning;
|
|
92
|
+
// Keep only an explicit "thinking off"; effort alone must not force thinking on.
|
|
93
|
+
return reasoning.enabled === false ? { enabled: false } : undefined;
|
|
94
|
+
}
|
|
95
|
+
|
|
82
96
|
function latestMessageHasToolResult(messages: unknown): boolean {
|
|
83
97
|
if (!Array.isArray(messages)) return false;
|
|
84
98
|
const message = messages.at(-1);
|
package/src/ingress/codex.ts
CHANGED
|
@@ -35,12 +35,15 @@ export async function handleResponses(
|
|
|
35
35
|
...(museClientId === undefined ? {} : { museCodeClientId: museClientId }),
|
|
36
36
|
};
|
|
37
37
|
const codexIdentity = codexConversationIdentity(request.headers, rawPayload);
|
|
38
|
-
// Codex
|
|
38
|
+
// Codex and Muse Code apply their session effort to custom model
|
|
39
39
|
// providers. That preference describes a direct model, not a heterogeneous
|
|
40
|
-
// router, so let Dari select the reasoning effort for
|
|
41
|
-
//
|
|
42
|
-
|
|
43
|
-
|
|
40
|
+
// router, so let Dari select the reasoning effort for their routed turns;
|
|
41
|
+
// a `dari/model/<id>` keeps the chosen effort. Parsing above still
|
|
42
|
+
// validates malformed Responses reasoning controls.
|
|
43
|
+
const harness = codexIdentity !== undefined || museClientId !== undefined;
|
|
44
|
+
if (harness && explicitModelId(payload.model) === null) {
|
|
45
|
+
// Keep only an explicit thinking-off; a session effort must not force thinking on.
|
|
46
|
+
payload.reasoning = payload.reasoning?.enabled === false ? { enabled: false } : undefined;
|
|
44
47
|
}
|
|
45
48
|
const harnessConversationIdentity = codexIdentity
|
|
46
49
|
?? (museClientId === undefined ? undefined : museCodeConversationIdentity(request.headers));
|
|
@@ -24,6 +24,13 @@ function requiresWebSearch(item: InteractionItem): boolean {
|
|
|
24
24
|
return item.type === "hosted_tool_call" && item.tool === "web_search";
|
|
25
25
|
}
|
|
26
26
|
|
|
27
|
+
export function requestHasDocument(request: Pick<InteractionRequest, "items">): boolean {
|
|
28
|
+
return request.items.some((item) => {
|
|
29
|
+
const parts = item.type === "message" ? item.content : item.type === "tool_result" ? item.output : null;
|
|
30
|
+
return Array.isArray(parts) && parts.some((part) => part.type === "document");
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
|
|
27
34
|
function itemHasImage(item: InteractionItem): boolean {
|
|
28
35
|
if (item.type === "message") return item.content.some((part) => part.type === "image");
|
|
29
36
|
return item.type === "tool_result" && Array.isArray(item.output) && item.output.some((part) => part.type === "image");
|