@mupt-ai/dari-gateway 0.3.2 → 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.
Files changed (70) hide show
  1. package/README.md +10 -11
  2. package/dari-gateway.example.yaml +1 -1
  3. package/dist/context/interaction_fingerprint.js +10 -1
  4. package/dist/context/interaction_fingerprint_format.d.ts +4 -0
  5. package/dist/context/interaction_fingerprint_format.js +3 -0
  6. package/dist/context/provider_prompt_accounting.js +52 -4
  7. package/dist/context/routing_context.d.ts +6 -1
  8. package/dist/context/routing_context.js +40 -4
  9. package/dist/ingress/anthropic_messages.js +12 -0
  10. package/dist/ingress/codex.js +12 -7
  11. package/dist/interaction/capabilities.d.ts +1 -0
  12. package/dist/interaction/capabilities.js +6 -0
  13. package/dist/interaction/document.d.ts +2 -0
  14. package/dist/interaction/document.js +8 -0
  15. package/dist/interaction/types.d.ts +7 -1
  16. package/dist/observability/errors.js +19 -0
  17. package/dist/platform/platform.d.ts +14 -0
  18. package/dist/protocol/input/anthropic_messages.js +74 -1
  19. package/dist/protocol/input/chat_completions.js +6 -0
  20. package/dist/protocol/input/chat_completions_validation.js +8 -0
  21. package/dist/protocol/input/openai_responses.js +8 -0
  22. package/dist/protocol/output/anthropic_messages_output.js +7 -0
  23. package/dist/providers/key_check.d.ts +31 -0
  24. package/dist/providers/key_check.js +54 -0
  25. package/dist/providers/model_capabilities.d.ts +2 -0
  26. package/dist/providers/model_capabilities.js +15 -0
  27. package/dist/providers/pi_context.js +13 -5
  28. package/dist/runtime/harness_identity/conversation.d.ts +4 -0
  29. package/dist/runtime/harness_identity/extract.d.ts +1 -0
  30. package/dist/runtime/harness_identity/extract.js +7 -0
  31. package/dist/runtime/harness_identity/index.d.ts +1 -1
  32. package/dist/runtime/harness_identity/index.js +1 -1
  33. package/dist/runtime/prepare_routed_completion.js +8 -1
  34. package/dist/self_host/config.d.ts +1 -1
  35. package/dist/self_host/config.js +2 -1
  36. package/dist/self_host/main.js +38 -5
  37. package/dist/self_host/resolve.d.ts +1 -0
  38. package/dist/self_host/resolve.js +3 -3
  39. package/dist/strategies/selector_projection.js +3 -0
  40. package/dist/telemetry/report.d.ts +3 -1
  41. package/dist/telemetry/report.js +23 -0
  42. package/package.json +2 -2
  43. package/src/context/interaction_fingerprint.ts +15 -1
  44. package/src/context/interaction_fingerprint_format.ts +4 -0
  45. package/src/context/provider_prompt_accounting.ts +52 -6
  46. package/src/context/routing_context.ts +56 -2
  47. package/src/ingress/anthropic_messages.ts +15 -1
  48. package/src/ingress/codex.ts +12 -9
  49. package/src/interaction/capabilities.ts +7 -0
  50. package/src/interaction/document.ts +8 -0
  51. package/src/interaction/types.ts +8 -1
  52. package/src/observability/errors.ts +21 -0
  53. package/src/platform/platform.ts +22 -0
  54. package/src/protocol/input/anthropic_messages.ts +71 -1
  55. package/src/protocol/input/chat_completions.ts +6 -0
  56. package/src/protocol/input/chat_completions_validation.ts +8 -0
  57. package/src/protocol/input/openai_responses.ts +13 -0
  58. package/src/protocol/output/anthropic_messages_output.ts +7 -0
  59. package/src/providers/key_check.ts +76 -0
  60. package/src/providers/model_capabilities.ts +18 -0
  61. package/src/providers/pi_context.ts +14 -5
  62. package/src/runtime/harness_identity/conversation.ts +5 -0
  63. package/src/runtime/harness_identity/extract.ts +10 -0
  64. package/src/runtime/harness_identity/index.ts +1 -0
  65. package/src/runtime/prepare_routed_completion.ts +12 -1
  66. package/src/self_host/config.ts +5 -2
  67. package/src/self_host/main.ts +45 -5
  68. package/src/self_host/resolve.ts +3 -3
  69. package/src/strategies/selector_projection.ts +2 -0
  70. package/src/telemetry/report.ts +34 -1
@@ -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: imageFilter.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);
@@ -13,6 +13,7 @@ import {
13
13
  codexConversationIdentity,
14
14
  codexRequestPurpose,
15
15
  codexRequestContext,
16
+ museCodeConversationIdentity,
16
17
  } from "../runtime/harness_identity/index.js";
17
18
 
18
19
  export async function handleResponses(
@@ -33,17 +34,19 @@ export async function handleResponses(
33
34
  ...(agentContext === undefined ? {} : { agentContext }),
34
35
  ...(museClientId === undefined ? {} : { museCodeClientId: museClientId }),
35
36
  };
36
- const harnessConversationIdentity = codexConversationIdentity(
37
- request.headers,
38
- rawPayload,
39
- );
40
- // Codex applies its global model_reasoning_effort setting to custom model
37
+ const codexIdentity = codexConversationIdentity(request.headers, rawPayload);
38
+ // Codex and Muse Code apply their session effort to custom model
41
39
  // providers. That preference describes a direct model, not a heterogeneous
42
- // router, so let Dari select the reasoning effort for verified Codex turns.
43
- // Parsing above still validates malformed Responses reasoning controls.
44
- if (harnessConversationIdentity !== undefined && explicitModelId(payload.model) === null) {
45
- payload.reasoning = undefined;
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;
46
47
  }
48
+ const harnessConversationIdentity = codexIdentity
49
+ ?? (museClientId === undefined ? undefined : museCodeConversationIdentity(request.headers));
47
50
  return await handleRoutedCompletion({
48
51
  request,
49
52
  routerId,
@@ -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
+ }
@@ -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.
@@ -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.map((rawBlock, index) => {
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");
@@ -19,6 +19,11 @@ export type HarnessConversationIdentity =
19
19
  kind: "openai_chat_session";
20
20
  value: string;
21
21
  qualifiers?: string[];
22
+ }
23
+ | {
24
+ kind: "muse_code_thread";
25
+ value: string;
26
+ qualifiers?: string[];
22
27
  };
23
28
 
24
29
  export type HarnessConversationScope = {