@mupt-ai/dari-gateway 0.3.3 → 0.3.5
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 +7 -5
- 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/models/canonical.d.ts +2 -0
- package/dist/models/canonical.js +3 -0
- package/dist/models/canonical_model_ids.json +33 -0
- package/dist/models/runtime_overrides.js +14 -0
- 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 +8 -2
- package/dist/self_host/config.js +30 -1
- package/dist/self_host/main.d.ts +4 -1
- package/dist/self_host/main.js +62 -6
- 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 +3 -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/models/canonical.ts +4 -0
- package/src/models/canonical_model_ids.json +33 -0
- package/src/models/runtime_overrides.ts +14 -0
- 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 +33 -3
- package/src/self_host/main.ts +68 -6
- package/src/self_host/resolve.ts +3 -3
- package/src/strategies/selector_projection.ts +2 -0
- package/src/telemetry/report.ts +34 -1
|
@@ -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");
|
|
@@ -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";
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
[
|
|
2
|
+
"openai/gpt-6-astra",
|
|
3
|
+
"openai/gpt-6.1-terra",
|
|
4
|
+
"openai/gpt-6.1-sol",
|
|
5
|
+
"openai/gpt-6-sol",
|
|
6
|
+
"openai/gpt-6-luna",
|
|
7
|
+
"openai/gpt-5.6-sol",
|
|
8
|
+
"openai/gpt-5.6-terra",
|
|
9
|
+
"openai/gpt-5.6-luna",
|
|
10
|
+
"anthropic/claude-opus-5-5",
|
|
11
|
+
"anthropic/claude-opus-5",
|
|
12
|
+
"anthropic/claude-sonnet-5-5",
|
|
13
|
+
"anthropic/claude-sonnet-5",
|
|
14
|
+
"anthropic/claude-fable-5-2",
|
|
15
|
+
"anthropic/claude-fable-5-1",
|
|
16
|
+
"anthropic/claude-fable-5",
|
|
17
|
+
"anthropic/claude-haiku-5-5",
|
|
18
|
+
"anthropic/claude-haiku-4-5",
|
|
19
|
+
"meta/muse-wonder-1.0",
|
|
20
|
+
"meta/muse-spark-1.3",
|
|
21
|
+
"meta/muse-ember",
|
|
22
|
+
"meta/muse-spark-1.2",
|
|
23
|
+
"moonshotai/Kimi-K3",
|
|
24
|
+
"zai-org/GLM-5.3",
|
|
25
|
+
"zai-org/GLM-5.3-Flash",
|
|
26
|
+
"deepseek-ai/DeepSeek-V4-Pro-0813",
|
|
27
|
+
"zai-org/GLM-5.2",
|
|
28
|
+
"deepseek-ai/DeepSeek-V4-Flash-0731",
|
|
29
|
+
"deepseek-ai/DeepSeek-V4.1-Flash-1017",
|
|
30
|
+
"deepseek-ai/DeepSeek-V4.1-Flash",
|
|
31
|
+
"xai/grok-4.6",
|
|
32
|
+
"xai/grok-4.7"
|
|
33
|
+
]
|
|
@@ -29,6 +29,10 @@ const MODEL_COMPAT_OVERRIDES: Record<string, Record<string, unknown>> = {
|
|
|
29
29
|
forceAdaptiveThinking: true,
|
|
30
30
|
supportsTemperature: false,
|
|
31
31
|
},
|
|
32
|
+
"anthropic/claude-fable-5-2": {
|
|
33
|
+
forceAdaptiveThinking: true,
|
|
34
|
+
supportsTemperature: false,
|
|
35
|
+
},
|
|
32
36
|
"anthropic/claude-opus-5-5": {
|
|
33
37
|
forceAdaptiveThinking: true,
|
|
34
38
|
supportsTemperature: false,
|
|
@@ -68,6 +72,14 @@ const MODEL_COMPAT_OVERRIDES: Record<string, Record<string, unknown>> = {
|
|
|
68
72
|
supportsExplicitPromptCacheMode: true,
|
|
69
73
|
supportsReasoningConfigurationUpdate: true,
|
|
70
74
|
},
|
|
75
|
+
"openai/gpt-6.1-terra": {
|
|
76
|
+
supportsStrictMode: true,
|
|
77
|
+
supportsOpenAIGrammarTools: true,
|
|
78
|
+
supportsAdditionalTools: true,
|
|
79
|
+
supportsToolSearch: true,
|
|
80
|
+
supportsExplicitPromptCacheMode: true,
|
|
81
|
+
supportsReasoningConfigurationUpdate: true,
|
|
82
|
+
},
|
|
71
83
|
"openai/gpt-6-sol": {
|
|
72
84
|
supportsStrictMode: true,
|
|
73
85
|
supportsOpenAIGrammarTools: true,
|
|
@@ -144,7 +156,9 @@ const NATIVE_REASONING_COMPAT_KEYS: ReadonlySet<string> = new Set([
|
|
|
144
156
|
const MODEL_INPUT_OVERRIDES: Record<string, string[]> = {
|
|
145
157
|
"anthropic/claude-haiku-5-5": ["text", "image"],
|
|
146
158
|
"anthropic/claude-sonnet-5-5": ["text", "image"],
|
|
159
|
+
"meta/muse-ember": ["text", "image"],
|
|
147
160
|
"openai/gpt-6.1-sol": ["text", "image"],
|
|
161
|
+
"openai/gpt-6.1-terra": ["text", "image"],
|
|
148
162
|
"openai/gpt-6-sol": ["text", "image"],
|
|
149
163
|
"openai/gpt-6-luna": ["text", "image"],
|
|
150
164
|
"openai/gpt-5.6-luna": ["text", "image"],
|
|
@@ -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({
|