@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.
- package/README.md +10 -11
- package/dari-gateway.example.yaml +1 -1
- package/dist/context/interaction_fingerprint.js +10 -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 +12 -7
- 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/harness_identity/conversation.d.ts +4 -0
- package/dist/runtime/harness_identity/extract.d.ts +1 -0
- package/dist/runtime/harness_identity/extract.js +7 -0
- package/dist/runtime/harness_identity/index.d.ts +1 -1
- package/dist/runtime/harness_identity/index.js +1 -1
- package/dist/runtime/prepare_routed_completion.js +8 -1
- package/dist/self_host/config.d.ts +1 -1
- package/dist/self_host/config.js +2 -1
- package/dist/self_host/main.js +38 -5
- 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.ts +15 -1
- 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 +12 -9
- 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/harness_identity/conversation.ts +5 -0
- package/src/runtime/harness_identity/extract.ts +10 -0
- package/src/runtime/harness_identity/index.ts +1 -0
- package/src/runtime/prepare_routed_completion.ts +12 -1
- package/src/self_host/config.ts +5 -2
- package/src/self_host/main.ts +45 -5
- package/src/self_host/resolve.ts +3 -3
- package/src/strategies/selector_projection.ts +2 -0
- package/src/telemetry/report.ts +34 -1
package/README.md
CHANGED
|
@@ -36,27 +36,26 @@ The gateway is the same runtime that serves Dari's hosted routing service. Choos
|
|
|
36
36
|
|
|
37
37
|
## Run
|
|
38
38
|
|
|
39
|
-
Requires [Bun](https://bun.sh) 1.3 or later.
|
|
39
|
+
Requires [Bun](https://bun.sh) 1.3 or later. Write a `dari-gateway.yaml` (start from [`dari-gateway.example.yaml`](dari-gateway.example.yaml)), then:
|
|
40
40
|
|
|
41
41
|
```bash
|
|
42
|
-
cp dari-gateway.example.yaml dari-gateway.yaml # edit routers and models
|
|
43
42
|
export DARI_GATEWAY_KEYS="$(openssl rand -hex 24)" # keys your clients send
|
|
44
43
|
export ANTHROPIC_API_KEY=... OPENAI_API_KEY=... # optional with subscriptions
|
|
45
|
-
|
|
46
|
-
bun run start
|
|
44
|
+
bunx @mupt-ai/dari-gateway
|
|
47
45
|
```
|
|
48
46
|
|
|
49
|
-
Or with Docker
|
|
47
|
+
Or with Docker:
|
|
50
48
|
|
|
51
49
|
```bash
|
|
52
|
-
docker build -t dari-gateway .
|
|
53
50
|
docker run -p 8080:8080 \
|
|
54
51
|
-e DARI_GATEWAY_KEYS -e ANTHROPIC_API_KEY \
|
|
55
52
|
-v "$PWD/dari-gateway.yaml:/config/dari-gateway.yaml" \
|
|
56
53
|
-v dari-gateway-data:/data \
|
|
57
|
-
dari-gateway
|
|
54
|
+
ghcr.io/mupt-ai/dari-gateway
|
|
58
55
|
```
|
|
59
56
|
|
|
57
|
+
From a clone, `bun install && bun run start` or `docker build -t dari-gateway .` run the same thing.
|
|
58
|
+
|
|
60
59
|
| Variable | Meaning |
|
|
61
60
|
| --- | --- |
|
|
62
61
|
| `DARI_GATEWAY_KEYS` | Required. Comma-separated bearer keys clients authenticate with. |
|
|
@@ -65,7 +64,7 @@ docker run -p 8080:8080 \
|
|
|
65
64
|
| `DARI_AUTO_ROUTING_API_KEY` | Optional. Select among a router's models with Dari Auto Routing. |
|
|
66
65
|
| `PORT` | Listen port. Default `8080`. |
|
|
67
66
|
| `DARI_GATEWAY_HOST` | Listen address. Default `127.0.0.1`, so only this machine can connect. The container image sets `0.0.0.0`; set it yourself to expose a process on your network. |
|
|
68
|
-
| Provider keys | Each provider's standard variable, such as `ANTHROPIC_API_KEY`, `OPENAI_API_KEY`, `META_API_KEY`, or `OPENROUTER_API_KEY`. |
|
|
67
|
+
| Provider keys | Each provider's standard variable, such as `ANTHROPIC_API_KEY`, `OPENAI_API_KEY`, `META_API_KEY`, or `OPENROUTER_API_KEY`. The gateway checks each key at startup and will not start if a provider rejects one. |
|
|
69
68
|
|
|
70
69
|
## Configure
|
|
71
70
|
|
|
@@ -74,7 +73,7 @@ routers:
|
|
|
74
73
|
default: # serves /v1/...
|
|
75
74
|
models:
|
|
76
75
|
- anthropic/claude-sonnet-5-5
|
|
77
|
-
- id: openai/gpt-
|
|
76
|
+
- id: openai/gpt-6.1-sol
|
|
78
77
|
thinking: [medium, high]
|
|
79
78
|
subscriptions: true # prefer connected subscriptions over API keys
|
|
80
79
|
sessionPinning: false # route each session and subagent once, then keep its model
|
|
@@ -121,9 +120,9 @@ The root entry needs no Bun APIs, so any runtime with web-standard `fetch`, `Req
|
|
|
121
120
|
Platforms can also:
|
|
122
121
|
|
|
123
122
|
- implement `resolveProviderCredential({ provider, model, routerId, organizationId, apiKey, clientHarness? })` to supply a per-request `{ apiKey, baseUrl?, headers?, subscription? }`, such as a tenant's own key, in place of the router's resolved key. With `subscription` (`"openai_codex"`, `"anthropic_claude_code"`, or `"meta_muse"`), `apiKey` is that plan's access token: Codex's `auth.json` `tokens.access_token`, the token `claude setup-token` prints, or the Model API key a Meta sign-in mints. The platform keeps it fresh. Strict routers serve these supplied plans, but never subscriptions the gateway resolved itself. A plan serves only its own provider's models; a Claude Code plan serves only Claude Code clients, and a Muse Code plan only Muse Code clients (recognized by their `x-client-id`);
|
|
124
|
-
- resolve routers with `strict: true` to serve only the requested `dari/model/<id>` at the requested thinking level, with no selector, lease, or personal subscription. Set `strict_providers: ["openai"]` on the resolved router to permit any bundled-catalog model from that provider without enumerating `enabled_models`. Use `dari/model/openai/gpt-
|
|
123
|
+
- resolve routers with `strict: true` to serve only the requested `dari/model/<id>` at the requested thinking level, with no selector, lease, or personal subscription. Set `strict_providers: ["openai"]` on the resolved router to permit any bundled-catalog model from that provider without enumerating `enabled_models`. Use `dari/model/openai/gpt-6.1-sol`; bare IDs and unknown models remain refused. This grant is ignored on non-strict routers and does not discover uncatalogued models;
|
|
125
124
|
- resolve routers with `session_pinning: true` to route each session once and serve that pair for the rest of the session. Each subagent (Claude Code agent id, Codex subagent thread, OpenCode child session) is routed once on its own. Pins are kept through `readSessionPin` and `writeSessionPin`, keyed by router and an opaque pin key; background requests and `dari/model/<id>` requests never use them;
|
|
126
|
-
- read exact usage from `recordRequest`: the summary carries input, output, cache-read, and cache-write tokens and `total_cost_usd`, preferring the provider's billed cost (`cost_source: "provider"`, from OpenRouter and Vercel AI Gateway). Failed and aborted requests are recorded too;
|
|
125
|
+
- read exact usage from `recordRequest`: the summary carries input, output, cache-read, and cache-write tokens and `total_cost_usd`, preferring the provider's billed cost (`cost_source: "provider"`, from OpenRouter and Vercel AI Gateway). Each record also carries the router's non-secret routing settings as `router` (enabled models, thinking levels, and the strict, speculative-routing, and session-pinning flags). Failed and aborted requests are recorded too;
|
|
127
126
|
- attribute cost with `X-Dari-Tags: run=r1,trial=3`, which is parsed into `RequestRecord.tags` and the `recordUsage` payload's `tags`.
|
|
128
127
|
|
|
129
128
|
## Development
|
|
@@ -59,7 +59,7 @@ export function interactionLooseChain(routerId, request) {
|
|
|
59
59
|
// transcript step. Growing history and tool changes preserve identity, while
|
|
60
60
|
// unrelated work reusing the same harness session gets a new group.
|
|
61
61
|
export function interactionConversationPrefix(request) {
|
|
62
|
-
const firstStep = conversationFingerprintSteps(looseFingerprintSteps(request.sourceProtocol, request.items)).at(0);
|
|
62
|
+
const firstStep = conversationFingerprintSteps(looseFingerprintSteps(request.sourceProtocol, request.items.filter((item) => !isOpenClawRuntimeContext(item)))).at(0);
|
|
63
63
|
return sha256(stableStringify({
|
|
64
64
|
version: CONVERSATION_PREFIX_VERSION,
|
|
65
65
|
firstStep: firstStep === undefined
|
|
@@ -67,6 +67,15 @@ export function interactionConversationPrefix(request) {
|
|
|
67
67
|
: canonicalInteractionFingerprintStep(firstStep),
|
|
68
68
|
}));
|
|
69
69
|
}
|
|
70
|
+
// OpenClaw appends a user message of runtime context (active exec sessions,
|
|
71
|
+
// subagents) to each request and drops it from later history. On a first
|
|
72
|
+
// turn it merges into the first transcript step, so leaving it in would give
|
|
73
|
+
// the first turn a different conversation than the turns after it.
|
|
74
|
+
const OPENCLAW_RUNTIME_CONTEXT = "<<<BEGIN_OPENCLAW_INTERNAL_CONTEXT>>>";
|
|
75
|
+
function isOpenClawRuntimeContext(item) {
|
|
76
|
+
return item.type === "message" && item.role === "user" && item.content.length > 0 &&
|
|
77
|
+
item.content.every((part) => part.type === "text" && part.text.trimStart().startsWith(OPENCLAW_RUNTIME_CONTEXT));
|
|
78
|
+
}
|
|
70
79
|
export function interactionPrefixContext(routerId, request, targets) {
|
|
71
80
|
const chainByModel = new Map();
|
|
72
81
|
const itemIndexByHashByModel = new Map();
|
|
@@ -6,6 +6,10 @@ export type FingerprintContent = string | Array<{
|
|
|
6
6
|
type: "image";
|
|
7
7
|
url: string;
|
|
8
8
|
detail?: "auto" | "low" | "high" | "original";
|
|
9
|
+
} | {
|
|
10
|
+
type: "document";
|
|
11
|
+
mimeType: "application/pdf";
|
|
12
|
+
data: string;
|
|
9
13
|
} | {
|
|
10
14
|
type: "tool_reference";
|
|
11
15
|
toolName: string;
|
|
@@ -83,6 +83,9 @@ export function interactionFingerprintContent(content, shape) {
|
|
|
83
83
|
toolName: part.toolName,
|
|
84
84
|
};
|
|
85
85
|
}
|
|
86
|
+
if (part.type === "document") {
|
|
87
|
+
return { type: "document", mimeType: part.mimeType, data: part.data };
|
|
88
|
+
}
|
|
86
89
|
return {
|
|
87
90
|
type: "image",
|
|
88
91
|
url: part.url,
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { constants as zlibConstants, inflateSync } from "node:zlib";
|
|
1
2
|
import { isRecord, stableStringify, } from "@mupt-ai/dari-router/policy-engine";
|
|
2
3
|
import { toolsByWireName } from "../interaction/tool_identity.js";
|
|
3
4
|
import { buildContext } from "../providers/pi_context.js";
|
|
@@ -10,6 +11,45 @@ const IMAGE_ESTIMATED_TOKENS = {
|
|
|
10
11
|
original: 2048,
|
|
11
12
|
};
|
|
12
13
|
const CHARS_PER_ESTIMATED_TOKEN = 4;
|
|
14
|
+
// Anthropic bills each PDF page as its text plus a page image: 1,500-3,000
|
|
15
|
+
// tokens. Use the top of that range so a long PDF cannot look like it fits.
|
|
16
|
+
const PDF_ESTIMATED_TOKENS_PER_PAGE = 3000;
|
|
17
|
+
const PDF_PAGE_PATTERN = /\/Type\s*\/Page(?![a-zA-Z])/g;
|
|
18
|
+
const PDF_OBJECT_STREAM_START = /\/Type\s*\/ObjStm[\s\S]*?stream\r?\n/g;
|
|
19
|
+
// Object streams hold small dictionaries. The cap is total inflated bytes per
|
|
20
|
+
// PDF, so a compression bomb in a request costs at most this much memory.
|
|
21
|
+
const PDF_OBJECT_STREAM_INFLATE_BUDGET = 4 * 1024 * 1024;
|
|
22
|
+
function estimatedPdfPages(base64) {
|
|
23
|
+
const bytes = Buffer.from(base64, "base64");
|
|
24
|
+
// latin1 maps each byte to one character, so string offsets are byte offsets.
|
|
25
|
+
const pdf = bytes.toString("latin1");
|
|
26
|
+
const visible = pdf.match(PDF_PAGE_PATTERN)?.length ?? 0;
|
|
27
|
+
if (visible > 0)
|
|
28
|
+
return visible;
|
|
29
|
+
// PDF 1.5+ may keep every page dictionary in compressed object streams.
|
|
30
|
+
let hidden = 0;
|
|
31
|
+
let budget = PDF_OBJECT_STREAM_INFLATE_BUDGET;
|
|
32
|
+
for (const match of pdf.matchAll(PDF_OBJECT_STREAM_START)) {
|
|
33
|
+
const start = match.index + match[0].length;
|
|
34
|
+
const end = pdf.indexOf("endstream", start);
|
|
35
|
+
if (end === -1 || budget <= 0)
|
|
36
|
+
break;
|
|
37
|
+
try {
|
|
38
|
+
const objects = inflateSync(bytes.subarray(start, end), {
|
|
39
|
+
finishFlush: zlibConstants.Z_SYNC_FLUSH,
|
|
40
|
+
maxOutputLength: budget,
|
|
41
|
+
});
|
|
42
|
+
budget -= objects.length;
|
|
43
|
+
hidden += objects.toString("latin1").match(PDF_PAGE_PATTERN)?.length ?? 0;
|
|
44
|
+
}
|
|
45
|
+
catch (error) {
|
|
46
|
+
// Over budget: stop. Another filter or bad data: count what is readable.
|
|
47
|
+
if (error instanceof RangeError)
|
|
48
|
+
break;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
return Math.max(1, hidden);
|
|
52
|
+
}
|
|
13
53
|
// Cost and cache-lookback estimates are candidate-specific because compatible
|
|
14
54
|
// continuation state and provider serialization can change what each model
|
|
15
55
|
// sees.
|
|
@@ -94,7 +134,7 @@ export function promptMetrics(request, target) {
|
|
|
94
134
|
};
|
|
95
135
|
return {
|
|
96
136
|
blockDepth: messages.reduce((total, message) => total + providerVisibleBlockCount(message), 0),
|
|
97
|
-
serializedChars: stableStringify(prompt).length + accountingInput.
|
|
137
|
+
serializedChars: stableStringify(prompt).length + accountingInput.attachmentEstimatedChars,
|
|
98
138
|
};
|
|
99
139
|
}
|
|
100
140
|
function providerVisibleMessage(value) {
|
|
@@ -162,12 +202,20 @@ function providerVisibleTools(request, contextTools, target) {
|
|
|
162
202
|
];
|
|
163
203
|
}
|
|
164
204
|
function promptAccountingInput(request) {
|
|
165
|
-
let
|
|
205
|
+
let attachmentEstimatedChars = 0;
|
|
206
|
+
// Attachments are estimated rather than serialized: their base64 is not
|
|
207
|
+
// what the provider bills, and a PDF must not reach the serializer of a
|
|
208
|
+
// model that cannot carry it.
|
|
166
209
|
const sanitizeContent = (parts) => parts.map((part) => {
|
|
210
|
+
if (part.type === "document") {
|
|
211
|
+
attachmentEstimatedChars +=
|
|
212
|
+
estimatedPdfPages(part.data) * PDF_ESTIMATED_TOKENS_PER_PAGE * CHARS_PER_ESTIMATED_TOKEN;
|
|
213
|
+
return { type: "text", text: "" };
|
|
214
|
+
}
|
|
167
215
|
if (part.type !== "image")
|
|
168
216
|
return part;
|
|
169
217
|
const detail = part.detail ?? "auto";
|
|
170
|
-
|
|
218
|
+
attachmentEstimatedChars +=
|
|
171
219
|
IMAGE_ESTIMATED_TOKENS[detail] * CHARS_PER_ESTIMATED_TOKEN;
|
|
172
220
|
return { ...part, url: ACCOUNTING_IMAGE_URL };
|
|
173
221
|
});
|
|
@@ -182,6 +230,6 @@ function promptAccountingInput(request) {
|
|
|
182
230
|
});
|
|
183
231
|
return {
|
|
184
232
|
request: { ...request, items },
|
|
185
|
-
|
|
233
|
+
attachmentEstimatedChars,
|
|
186
234
|
};
|
|
187
235
|
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
import { type ActiveLease, type PendingLease, type CandidateCostEstimate, type ChainHits, type PrefixHit, type ChatMessage, type PreviousDecision, type PricingLookup, type SelectorLeaseHistoryEntry, type StrategyCandidateResolution } from "@mupt-ai/dari-router/policy-engine";
|
|
1
|
+
import { type ActiveLease, type PendingLease, type CandidateCostEstimate, type ChainHits, type PrefixHit, type ChatMessage, type PreviousDecision, type PricingLookup, type ReasoningEffort, type SelectorLeaseHistoryEntry, type StrategyCandidateResolution } from "@mupt-ai/dari-router/policy-engine";
|
|
2
2
|
import { type InteractionRequest, type ProviderIdentity } from "../interaction/types.js";
|
|
3
|
+
import { modelMetadataLookup } from "../providers/pi.js";
|
|
3
4
|
import { type SelectorImage } from "../strategies/selector_projection.js";
|
|
4
5
|
import type { ResolvedRouter } from "../types.js";
|
|
5
6
|
import { type InteractionPrefixContext } from "./interaction_fingerprint.js";
|
|
@@ -38,3 +39,7 @@ export declare function buildRoutingContext(args: {
|
|
|
38
39
|
explicitConversationId?: string;
|
|
39
40
|
receivedAt?: Date;
|
|
40
41
|
}): Promise<RoutingContext>;
|
|
42
|
+
export declare function clampRequestedEffort(interaction: InteractionRequest, credentials: {
|
|
43
|
+
strict?: boolean;
|
|
44
|
+
model_thinking_levels?: Record<string, ReasoningEffort[]>;
|
|
45
|
+
}, metadataLookup: ReturnType<typeof modelMetadataLookup>): InteractionRequest;
|
|
@@ -1,14 +1,15 @@
|
|
|
1
|
-
import { prepareCandidates, } from "@mupt-ai/dari-router/policy-engine";
|
|
1
|
+
import { prepareCandidates, isReasoningEffort, } from "@mupt-ai/dari-router/policy-engine";
|
|
2
2
|
import { randomUUID } from "node:crypto";
|
|
3
3
|
import { HttpError } from "../http/errors.js";
|
|
4
4
|
import { effectiveContextWindow } from "../models/context_limits.js";
|
|
5
5
|
import { keySourceForModel } from "../models/execution_provider.js";
|
|
6
|
-
import { requiredCapabilitiesFor } from "../interaction/capabilities.js";
|
|
6
|
+
import { requestHasDocument, requiredCapabilitiesFor } from "../interaction/capabilities.js";
|
|
7
7
|
import { providerIdentityFromModel } from "../interaction/provider_continuation_state.js";
|
|
8
8
|
import { isBackgroundRequest } from "../interaction/types.js";
|
|
9
9
|
import { requireCandidateModels } from "../models/ids.js";
|
|
10
|
+
import { explicitModelId } from "../protocol/input/router_model.js";
|
|
10
11
|
import { modelMetadataLookup } from "../providers/pi.js";
|
|
11
|
-
import { modelSupportsImageInput, modelSupportsHostedWebSearch, modelSupportsReasoningConfigurationUpdate, providerApiCanSerializeResponseFormat, } from "../providers/model_capabilities.js";
|
|
12
|
+
import { modelSupportsDocumentInput, modelSupportsImageInput, modelSupportsHostedWebSearch, modelSupportsReasoningConfigurationUpdate, providerApiCanSerializeResponseFormat, } from "../providers/model_capabilities.js";
|
|
12
13
|
import { selectorMessagesForInteraction, } from "../strategies/selector_projection.js";
|
|
13
14
|
import { interactionPrefixContext, interactionPrefixLookupHashes, } from "./interaction_fingerprint.js";
|
|
14
15
|
import { pricingLookupForRouter } from "./pricing.js";
|
|
@@ -64,6 +65,7 @@ export async function buildRoutingContext(args) {
|
|
|
64
65
|
enabled: args.credentials.image_capability_filtering !== false,
|
|
65
66
|
supportsImageInput: (model) => capabilityLookup(model).supportsImageInput,
|
|
66
67
|
});
|
|
68
|
+
const documentCapableModels = documentCapableCandidates(imageFilter.candidateModels, requestHasDocument(args.interaction), (model) => modelSupportsDocumentInput(model, metadataLookup(model)));
|
|
67
69
|
const prefix = derivePrefixContext({
|
|
68
70
|
routerId: args.routerId,
|
|
69
71
|
interaction: args.interaction,
|
|
@@ -81,7 +83,7 @@ export async function buildRoutingContext(args) {
|
|
|
81
83
|
const prefixHits = (background ? [] : args.credentials.prefix_hits ?? []).filter((hit) => args.explicitConversationId === undefined
|
|
82
84
|
|| hit.conversation_id === args.explicitConversationId);
|
|
83
85
|
const prepared = prepareCandidates({
|
|
84
|
-
candidateModels:
|
|
86
|
+
candidateModels: documentCapableModels,
|
|
85
87
|
metadataLookup: capabilityLookup,
|
|
86
88
|
requiredCapabilities: imageFilter.requiredCapabilities,
|
|
87
89
|
requestedReasoningEffort: args.interaction.reasoning?.effort,
|
|
@@ -195,6 +197,17 @@ function resolveImageCapableCandidates(args) {
|
|
|
195
197
|
.map((model) => ({ model, reason: "image_input_unsupported" })),
|
|
196
198
|
};
|
|
197
199
|
}
|
|
200
|
+
// A PDF can only be sent natively, so a conversation carrying one must not
|
|
201
|
+
// route (or hold a lease) to a model that cannot read it.
|
|
202
|
+
function documentCapableCandidates(candidateModels, hasDocument, supportsDocumentInput) {
|
|
203
|
+
if (!hasDocument)
|
|
204
|
+
return candidateModels;
|
|
205
|
+
const capable = candidateModels.filter(supportsDocumentInput);
|
|
206
|
+
if (capable.length === 0) {
|
|
207
|
+
throw new HttpError(400, "This conversation contains a PDF document, but no enabled model on this router accepts PDF input.", "no_document_capable_candidates");
|
|
208
|
+
}
|
|
209
|
+
return capable;
|
|
210
|
+
}
|
|
198
211
|
function derivePrefixContext(args) {
|
|
199
212
|
try {
|
|
200
213
|
return interactionPrefixContext(args.routerId, args.interaction, args.targetsByModel);
|
|
@@ -209,3 +222,26 @@ function derivePrefixContext(args) {
|
|
|
209
222
|
};
|
|
210
223
|
}
|
|
211
224
|
}
|
|
225
|
+
// An explicit `dari/model/<id>` serves the requested thinking level when the
|
|
226
|
+
// model offers it and otherwise its default level, so the request itself drops
|
|
227
|
+
// an unsupported level before routing and provider validation see it. A strict
|
|
228
|
+
// router never substitutes, so it keeps the level and fails on it.
|
|
229
|
+
export function clampRequestedEffort(interaction, credentials, metadataLookup) {
|
|
230
|
+
const requested = interaction.reasoning?.effort;
|
|
231
|
+
const modelId = explicitModelId(interaction.model);
|
|
232
|
+
if (requested === undefined || modelId === null || credentials.strict === true)
|
|
233
|
+
return interaction;
|
|
234
|
+
const supported = metadataLookup(modelId).supportedThinkingLevels.filter(isReasoningEffort);
|
|
235
|
+
const configured = credentials.model_thinking_levels?.[modelId] ?? supported;
|
|
236
|
+
if (configured.includes(requested) && supported.includes(requested))
|
|
237
|
+
return interaction;
|
|
238
|
+
// The effort implied thinking on and its effort-derived fields (baseEffort,
|
|
239
|
+
// configuration updates) must not leak into the default. Keep only an explicit
|
|
240
|
+
// thinking-off and any thinking budget.
|
|
241
|
+
const { enabled, budgetTokens } = interaction.reasoning;
|
|
242
|
+
const reasoning = {
|
|
243
|
+
...(enabled === false ? { enabled } : {}),
|
|
244
|
+
...(budgetTokens === undefined ? {} : { budgetTokens }),
|
|
245
|
+
};
|
|
246
|
+
return { ...interaction, reasoning: Object.keys(reasoning).length === 0 ? undefined : reasoning };
|
|
247
|
+
}
|
|
@@ -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 { claudeCodeRequestContext } from "./claude_code/request_context.js";
|
|
10
11
|
import { claudeCodeConversationIdentity, } from "../runtime/harness_identity/index.js";
|
|
@@ -24,6 +25,7 @@ export async function handleAnthropicMessages(request, routerId, selectorBackend
|
|
|
24
25
|
const payload = isClaudeCode
|
|
25
26
|
? {
|
|
26
27
|
...forwarded,
|
|
28
|
+
reasoning: routedReasoning(forwarded),
|
|
27
29
|
agentContext: claudeCodeRequestContext(request.headers),
|
|
28
30
|
// Native tool results may be followed by harness-injected text.
|
|
29
31
|
// Preserve their structural continuation semantics, not prompt inference.
|
|
@@ -58,6 +60,16 @@ export async function handleAnthropicMessages(request, routerId, selectorBackend
|
|
|
58
60
|
return response;
|
|
59
61
|
}
|
|
60
62
|
}
|
|
63
|
+
// Claude Code sends its session effort on every turn. That preference
|
|
64
|
+
// describes a direct model, not a heterogeneous router, so let Dari select the
|
|
65
|
+
// effort when the router routes; a `dari/model/<id>` keeps the chosen effort.
|
|
66
|
+
function routedReasoning(interaction) {
|
|
67
|
+
const reasoning = interaction.reasoning;
|
|
68
|
+
if (reasoning === undefined || explicitModelId(interaction.model) !== null)
|
|
69
|
+
return reasoning;
|
|
70
|
+
// Keep only an explicit "thinking off"; effort alone must not force thinking on.
|
|
71
|
+
return reasoning.enabled === false ? { enabled: false } : undefined;
|
|
72
|
+
}
|
|
61
73
|
function latestMessageHasToolResult(messages) {
|
|
62
74
|
if (!Array.isArray(messages))
|
|
63
75
|
return false;
|
package/dist/ingress/codex.js
CHANGED
|
@@ -8,7 +8,7 @@ import { openAIResponsesToInteraction } from "../protocol/input/openai_responses
|
|
|
8
8
|
import { parseJsonRequest } from "./json.js";
|
|
9
9
|
import { museCodeClientId } from "./muse_code.js";
|
|
10
10
|
import { handleRoutedCompletion } from "../runtime/routed_completion.js";
|
|
11
|
-
import { codexConversationIdentity, codexRequestPurpose, codexRequestContext, } from "../runtime/harness_identity/index.js";
|
|
11
|
+
import { codexConversationIdentity, codexRequestPurpose, codexRequestContext, museCodeConversationIdentity, } from "../runtime/harness_identity/index.js";
|
|
12
12
|
export async function handleResponses(request, routerId, selectorBackend) {
|
|
13
13
|
try {
|
|
14
14
|
const apiKey = bearerToken(request.headers);
|
|
@@ -23,14 +23,19 @@ export async function handleResponses(request, routerId, selectorBackend) {
|
|
|
23
23
|
...(agentContext === undefined ? {} : { agentContext }),
|
|
24
24
|
...(museClientId === undefined ? {} : { museCodeClientId: museClientId }),
|
|
25
25
|
};
|
|
26
|
-
const
|
|
27
|
-
// Codex
|
|
26
|
+
const codexIdentity = codexConversationIdentity(request.headers, rawPayload);
|
|
27
|
+
// Codex and Muse Code apply their session effort to custom model
|
|
28
28
|
// providers. That preference describes a direct model, not a heterogeneous
|
|
29
|
-
// router, so let Dari select the reasoning effort for
|
|
30
|
-
//
|
|
31
|
-
|
|
32
|
-
|
|
29
|
+
// router, so let Dari select the reasoning effort for their routed turns;
|
|
30
|
+
// a `dari/model/<id>` keeps the chosen effort. Parsing above still
|
|
31
|
+
// validates malformed Responses reasoning controls.
|
|
32
|
+
const harness = codexIdentity !== undefined || museClientId !== undefined;
|
|
33
|
+
if (harness && explicitModelId(payload.model) === null) {
|
|
34
|
+
// Keep only an explicit thinking-off; a session effort must not force thinking on.
|
|
35
|
+
payload.reasoning = payload.reasoning?.enabled === false ? { enabled: false } : undefined;
|
|
33
36
|
}
|
|
37
|
+
const harnessConversationIdentity = codexIdentity
|
|
38
|
+
?? (museClientId === undefined ? undefined : museCodeConversationIdentity(request.headers));
|
|
34
39
|
return await handleRoutedCompletion({
|
|
35
40
|
request,
|
|
36
41
|
routerId,
|
|
@@ -1,3 +1,4 @@
|
|
|
1
1
|
import type { HardCapability } from "@mupt-ai/dari-router/policy-engine";
|
|
2
2
|
import type { InteractionRequest } from "./types.js";
|
|
3
3
|
export declare function requiredCapabilitiesFor(request: Pick<InteractionRequest, "items" | "responseFormat" | "toolChoice" | "tools">): HardCapability[];
|
|
4
|
+
export declare function requestHasDocument(request: Pick<InteractionRequest, "items">): boolean;
|
|
@@ -17,6 +17,12 @@ export function requiredCapabilitiesFor(request) {
|
|
|
17
17
|
function requiresWebSearch(item) {
|
|
18
18
|
return item.type === "hosted_tool_call" && item.tool === "web_search";
|
|
19
19
|
}
|
|
20
|
+
export function requestHasDocument(request) {
|
|
21
|
+
return request.items.some((item) => {
|
|
22
|
+
const parts = item.type === "message" ? item.content : item.type === "tool_result" ? item.output : null;
|
|
23
|
+
return Array.isArray(parts) && parts.some((part) => part.type === "document");
|
|
24
|
+
});
|
|
25
|
+
}
|
|
20
26
|
function itemHasImage(item) {
|
|
21
27
|
if (item.type === "message")
|
|
22
28
|
return item.content.some((part) => part.type === "image");
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
const PDF_DATA_URL_PREFIX = "data:application/pdf;base64,";
|
|
2
|
+
/** The base64 payload of a PDF data URL, or undefined for anything else. */
|
|
3
|
+
export function parsePdfDataUrl(url) {
|
|
4
|
+
if (!url.startsWith(PDF_DATA_URL_PREFIX))
|
|
5
|
+
return undefined;
|
|
6
|
+
const data = url.slice(PDF_DATA_URL_PREFIX.length);
|
|
7
|
+
return data.length > 0 ? data : undefined;
|
|
8
|
+
}
|
|
@@ -32,11 +32,17 @@ export type InteractionImage = {
|
|
|
32
32
|
url: string;
|
|
33
33
|
detail?: "auto" | "low" | "high" | "original";
|
|
34
34
|
};
|
|
35
|
+
/** A base64-encoded PDF. Only Anthropic Messages targets accept documents. */
|
|
36
|
+
export type InteractionDocument = {
|
|
37
|
+
type: "document";
|
|
38
|
+
mimeType: "application/pdf";
|
|
39
|
+
data: string;
|
|
40
|
+
};
|
|
35
41
|
export type InteractionToolReference = {
|
|
36
42
|
type: "tool_reference";
|
|
37
43
|
toolName: string;
|
|
38
44
|
};
|
|
39
|
-
export type InteractionContent = InteractionText | InteractionImage | InteractionToolReference;
|
|
45
|
+
export type InteractionContent = InteractionText | InteractionImage | InteractionDocument | InteractionToolReference;
|
|
40
46
|
export type InteractionMessage = {
|
|
41
47
|
type: "message";
|
|
42
48
|
id?: string;
|
|
@@ -1,7 +1,26 @@
|
|
|
1
|
+
import { HttpError } from "../http/errors.js";
|
|
1
2
|
import { gatewayPlatform, } from "../platform/platform.js";
|
|
2
3
|
export function captureRequestError(error, context) {
|
|
4
|
+
logUnsupportedInput(error, context);
|
|
3
5
|
gatewayPlatform().captureRequestError(error, context);
|
|
4
6
|
}
|
|
7
|
+
/* Platforms report only 5xx, so a client sending a block the gateway cannot
|
|
8
|
+
carry would otherwise be invisible. Log the code and parameter path, never
|
|
9
|
+
request content. */
|
|
10
|
+
function logUnsupportedInput(error, context) {
|
|
11
|
+
if (!(error instanceof HttpError) || error.status >= 500)
|
|
12
|
+
return;
|
|
13
|
+
if (!error.code.startsWith("unsupported_") && !error.code.startsWith("no_"))
|
|
14
|
+
return;
|
|
15
|
+
console.warn("routing request unsupported input", JSON.stringify({
|
|
16
|
+
router_id: context.routerId,
|
|
17
|
+
request_id: context.requestId ?? null,
|
|
18
|
+
source_protocol: context.sourceProtocol,
|
|
19
|
+
error_code: error.code,
|
|
20
|
+
param: error.param ?? null,
|
|
21
|
+
message: error.message,
|
|
22
|
+
}));
|
|
23
|
+
}
|
|
5
24
|
/**
|
|
6
25
|
* Report failures deliberately caught outside the ingress exception path,
|
|
7
26
|
* including work after a response has started and process shutdown.
|
|
@@ -110,6 +110,20 @@ export type RequestRecord = {
|
|
|
110
110
|
zdr: boolean;
|
|
111
111
|
harnessConversationIdentity?: HarnessConversationIdentity;
|
|
112
112
|
tags: RequestTags;
|
|
113
|
+
router: RequestRecordRouter;
|
|
114
|
+
candidates: Array<{
|
|
115
|
+
model: string;
|
|
116
|
+
reasoningEffort: ReasoningEffort;
|
|
117
|
+
}>;
|
|
118
|
+
sessionPinKey?: string;
|
|
119
|
+
};
|
|
120
|
+
export type RequestRecordRouter = {
|
|
121
|
+
enabledModels: string[];
|
|
122
|
+
thinkingLevels: Record<string, ReasoningEffort[]>;
|
|
123
|
+
defaultThinkingLevels: Record<string, ReasoningEffort>;
|
|
124
|
+
strict: boolean;
|
|
125
|
+
speculativeRouting: boolean;
|
|
126
|
+
sessionPinning: boolean;
|
|
113
127
|
};
|
|
114
128
|
export type RequestTags = Record<string, string>;
|
|
115
129
|
export type RequestSummary = {
|
|
@@ -102,6 +102,18 @@ function appendAnthropicMessage(items, role, value, messageIndex) {
|
|
|
102
102
|
buffered.push(anthropicImage(rawBlock.source, `${param}.source`));
|
|
103
103
|
continue;
|
|
104
104
|
}
|
|
105
|
+
if (type === "document") {
|
|
106
|
+
if (role !== "user")
|
|
107
|
+
throw invalidRequest("Anthropic document blocks are only supported on user messages.", param);
|
|
108
|
+
buffered.push(...anthropicDocument(rawBlock, param));
|
|
109
|
+
continue;
|
|
110
|
+
}
|
|
111
|
+
if (type === "search_result") {
|
|
112
|
+
if (role !== "user")
|
|
113
|
+
throw invalidRequest("Anthropic search_result blocks are only supported on user messages.", param);
|
|
114
|
+
buffered.push(anthropicSearchResult(rawBlock, param));
|
|
115
|
+
continue;
|
|
116
|
+
}
|
|
105
117
|
if (type === "thinking" || type === "redacted_thinking") {
|
|
106
118
|
if (role !== "assistant")
|
|
107
119
|
throw invalidRequest(`${type} blocks require the assistant role.`, param);
|
|
@@ -212,6 +224,63 @@ function appendAnthropicMessage(items, role, value, messageIndex) {
|
|
|
212
224
|
}
|
|
213
225
|
flushMessage();
|
|
214
226
|
}
|
|
227
|
+
/* PDFs stay native documents; they route only to models that read PDFs.
|
|
228
|
+
Text and content documents become ordinary parts so every model can read
|
|
229
|
+
them. Title and context travel as a leading text part. Citations are not
|
|
230
|
+
requested from the provider, so the citations setting is dropped. */
|
|
231
|
+
function anthropicDocument(block, param) {
|
|
232
|
+
const source = block.source;
|
|
233
|
+
const sourceParam = `${param}.source`;
|
|
234
|
+
if (!isRecord(source))
|
|
235
|
+
throw invalidRequest(`${sourceParam} must be an object.`, sourceParam);
|
|
236
|
+
const header = [optionalString(block.title, `${param}.title`), optionalString(block.context, `${param}.context`)]
|
|
237
|
+
.filter((line) => line !== undefined && line.length > 0)
|
|
238
|
+
.join("\n");
|
|
239
|
+
const withHeader = (body) => header ? [{ type: "text", text: `Document: ${header}` }, ...body] : body;
|
|
240
|
+
const type = requiredString(source.type, `${sourceParam}.type`);
|
|
241
|
+
if (type === "base64") {
|
|
242
|
+
const mediaType = requiredString(source.media_type, `${sourceParam}.media_type`);
|
|
243
|
+
if (mediaType !== "application/pdf") {
|
|
244
|
+
throw new HttpError(400, "Base64 document sources must be application/pdf.", "unsupported_document_source", `${sourceParam}.media_type`);
|
|
245
|
+
}
|
|
246
|
+
const document = { type: "document", mimeType: "application/pdf", data: requiredString(source.data, `${sourceParam}.data`) };
|
|
247
|
+
return withHeader([document]);
|
|
248
|
+
}
|
|
249
|
+
if (type === "text") {
|
|
250
|
+
return withHeader([{ type: "text", text: requiredStringAllowEmpty(source.data, `${sourceParam}.data`) }]);
|
|
251
|
+
}
|
|
252
|
+
if (type === "content") {
|
|
253
|
+
if (typeof source.content === "string")
|
|
254
|
+
return withHeader([{ type: "text", text: source.content }]);
|
|
255
|
+
if (!Array.isArray(source.content))
|
|
256
|
+
throw invalidRequest(`${sourceParam}.content must be a string or an array.`, `${sourceParam}.content`);
|
|
257
|
+
return withHeader(source.content.map((part, index) => {
|
|
258
|
+
const partParam = `${sourceParam}.content.${index}`;
|
|
259
|
+
if (!isRecord(part))
|
|
260
|
+
throw invalidRequest(`${partParam} must be an object.`, partParam);
|
|
261
|
+
if (part.type === "text")
|
|
262
|
+
return { type: "text", text: requiredStringAllowEmpty(part.text, `${partParam}.text`) };
|
|
263
|
+
if (part.type === "image")
|
|
264
|
+
return anthropicImage(part.source, `${partParam}.source`);
|
|
265
|
+
throw new HttpError(400, `Document content block type '${String(part.type ?? "unknown")}' is not supported.`, "unsupported_content_block", partParam);
|
|
266
|
+
}));
|
|
267
|
+
}
|
|
268
|
+
throw new HttpError(400, `Anthropic document source type '${type}' is not supported; use a base64 PDF, text, or content source.`, "unsupported_document_source", `${sourceParam}.type`);
|
|
269
|
+
}
|
|
270
|
+
// Search results are text with provenance; they reach every model as text.
|
|
271
|
+
function anthropicSearchResult(block, param) {
|
|
272
|
+
const title = requiredStringAllowEmpty(block.title, `${param}.title`);
|
|
273
|
+
const source = requiredStringAllowEmpty(block.source, `${param}.source`);
|
|
274
|
+
if (!Array.isArray(block.content))
|
|
275
|
+
throw invalidRequest(`${param}.content must be an array.`, `${param}.content`);
|
|
276
|
+
const body = block.content.map((part, index) => {
|
|
277
|
+
const partParam = `${param}.content.${index}`;
|
|
278
|
+
if (!isRecord(part) || part.type !== "text")
|
|
279
|
+
throw invalidRequest(`${partParam} must be a text block.`, partParam);
|
|
280
|
+
return requiredStringAllowEmpty(part.text, `${partParam}.text`);
|
|
281
|
+
});
|
|
282
|
+
return { type: "text", text: [`Search result: ${title}`, `Source: ${source}`, "", ...body].join("\n") };
|
|
283
|
+
}
|
|
215
284
|
function anthropicImage(value, param) {
|
|
216
285
|
if (!isRecord(value))
|
|
217
286
|
throw invalidRequest(`${param} must be an object.`, param);
|
|
@@ -264,7 +333,7 @@ function anthropicToolResultContent(value, param) {
|
|
|
264
333
|
if (!Array.isArray(value))
|
|
265
334
|
throw invalidRequest(`${param} must be a string or an array.`, param);
|
|
266
335
|
return {
|
|
267
|
-
output: value.
|
|
336
|
+
output: value.flatMap((rawBlock, index) => {
|
|
268
337
|
const blockParam = `${param}.${index}`;
|
|
269
338
|
if (!isRecord(rawBlock))
|
|
270
339
|
throw invalidRequest(`${blockParam} must be an object.`, blockParam);
|
|
@@ -273,6 +342,10 @@ function anthropicToolResultContent(value, param) {
|
|
|
273
342
|
}
|
|
274
343
|
if (rawBlock.type === "image")
|
|
275
344
|
return anthropicImage(rawBlock.source, `${blockParam}.source`);
|
|
345
|
+
if (rawBlock.type === "document")
|
|
346
|
+
return anthropicDocument(rawBlock, blockParam);
|
|
347
|
+
if (rawBlock.type === "search_result")
|
|
348
|
+
return [anthropicSearchResult(rawBlock, blockParam)];
|
|
276
349
|
if (rawBlock.type === "tool_reference") {
|
|
277
350
|
return {
|
|
278
351
|
type: "tool_reference",
|
|
@@ -3,6 +3,7 @@ import { HttpError } from "../../http/errors.js";
|
|
|
3
3
|
import { toolsByWireName } from "../../interaction/tool_identity.js";
|
|
4
4
|
import { decodeUntrustedProviderContinuationState } from "../../interaction/provider_continuation_state.js";
|
|
5
5
|
import { validateChatCompletionsRequest } from "./chat_completions_validation.js";
|
|
6
|
+
import { parsePdfDataUrl } from "../../interaction/document.js";
|
|
6
7
|
export function chatCompletionsToInteraction(request) {
|
|
7
8
|
validateChatCompletionsRequest(request);
|
|
8
9
|
return internalChatCompletionsToInteraction(request);
|
|
@@ -177,6 +178,11 @@ function messageContent(content) {
|
|
|
177
178
|
if (part.type === "tool_reference") {
|
|
178
179
|
return { type: "text", text: `Referenced tool: ${String(part.tool_name)}` };
|
|
179
180
|
}
|
|
181
|
+
if (part.type === "file") {
|
|
182
|
+
// Validation guarantees a base64 PDF data URL.
|
|
183
|
+
const fileData = String(part.file.file_data);
|
|
184
|
+
return { type: "document", mimeType: "application/pdf", data: parsePdfDataUrl(fileData) };
|
|
185
|
+
}
|
|
180
186
|
const imageUrl = part.image_url;
|
|
181
187
|
const detail = imageUrl.detail;
|
|
182
188
|
return {
|
|
@@ -3,6 +3,7 @@ import { HttpError } from "../../http/errors.js";
|
|
|
3
3
|
import { parseDataImageUrl } from "../../interaction/image.js";
|
|
4
4
|
import { CANONICAL_ROUTER_MODEL, explicitModelId, unsupportedModelMessage } from "./router_model.js";
|
|
5
5
|
import { isProtocolToolCallId, TOOL_CALL_ID_MAX_LENGTH, } from "../../interaction/tool_identity.js";
|
|
6
|
+
import { parsePdfDataUrl } from "../../interaction/document.js";
|
|
6
7
|
export function validateChatCompletionsRequest(request) {
|
|
7
8
|
if (request.model !== CANONICAL_ROUTER_MODEL && explicitModelId(request.model) === null) {
|
|
8
9
|
throw new HttpError(400, unsupportedModelMessage(), "unsupported_model", "model");
|
|
@@ -103,6 +104,13 @@ function validateMessageContent(content, messageIndex, role) {
|
|
|
103
104
|
}
|
|
104
105
|
return;
|
|
105
106
|
}
|
|
107
|
+
if (part.type === "file") {
|
|
108
|
+
const fileData = isRecord(part.file) ? part.file.file_data : undefined;
|
|
109
|
+
if (typeof fileData !== "string" || parsePdfDataUrl(fileData) === undefined) {
|
|
110
|
+
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`);
|
|
111
|
+
}
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
106
114
|
if (part.type === "image_url") {
|
|
107
115
|
const imageUrl = part.image_url;
|
|
108
116
|
const url = isRecord(imageUrl) && typeof imageUrl.url === "string" ? imageUrl.url : "";
|