@mupt-ai/dari-gateway 0.0.0-stage → 0.2.0
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/LICENSE +202 -0
- package/README.md +135 -2
- package/dari-gateway.example.yaml +28 -0
- package/package.json +42 -4
- package/src/bun.ts +4 -0
- package/src/context/eval_catalog.ts +11 -0
- package/src/context/interaction_fingerprint.ts +691 -0
- package/src/context/interaction_fingerprint_format.ts +189 -0
- package/src/context/interaction_lease_depth.ts +150 -0
- package/src/context/lease_history.ts +200 -0
- package/src/context/pricing.ts +30 -0
- package/src/context/provider_prompt_accounting.ts +241 -0
- package/src/context/routing_context.ts +337 -0
- package/src/http/auth.ts +43 -0
- package/src/http/errors.ts +114 -0
- package/src/http/handler.ts +110 -0
- package/src/http/http2_server.ts +122 -0
- package/src/http/serve.ts +119 -0
- package/src/index.ts +22 -0
- package/src/ingress/anthropic_messages.ts +162 -0
- package/src/ingress/anthropic_models.ts +28 -0
- package/src/ingress/chat_completions.ts +49 -0
- package/src/ingress/claude_code/request_context.ts +36 -0
- package/src/ingress/codex.ts +81 -0
- package/src/ingress/codex_models.ts +113 -0
- package/src/ingress/json.ts +113 -0
- package/src/ingress/models.ts +55 -0
- package/src/interaction/capabilities.ts +30 -0
- package/src/interaction/image.ts +12 -0
- package/src/interaction/portable_reasoning_state.ts +25 -0
- package/src/interaction/provider_continuation_state.ts +36 -0
- package/src/interaction/tool_identity.ts +65 -0
- package/src/interaction/types.ts +282 -0
- package/src/models/context_limits.ts +8 -0
- package/src/models/execution_provider.ts +22 -0
- package/src/models/gateways.ts +73 -0
- package/src/models/ids.ts +26 -0
- package/src/models/provider_defaults.ts +47 -0
- package/src/models/runtime_overrides.ts +221 -0
- package/src/observability/errors.ts +25 -0
- package/src/platform/platform.ts +204 -0
- package/src/platform/routing_contract.ts +6 -0
- package/src/protocol/input/anthropic_messages.ts +531 -0
- package/src/protocol/input/chat_completions.ts +255 -0
- package/src/protocol/input/chat_completions_validation.ts +293 -0
- package/src/protocol/input/openai_responses.ts +749 -0
- package/src/protocol/input/router_model.ts +53 -0
- package/src/protocol/output/anthropic_messages.ts +32 -0
- package/src/protocol/output/anthropic_messages_output.ts +129 -0
- package/src/protocol/output/anthropic_messages_stream.ts +281 -0
- package/src/protocol/output/chat_completions.ts +451 -0
- package/src/protocol/output/chat_reasoning_detail.ts +19 -0
- package/src/protocol/output/delivered_stream_snapshot.ts +31 -0
- package/src/protocol/output/openai_responses.ts +30 -0
- package/src/protocol/output/openai_responses_output.ts +159 -0
- package/src/protocol/output/openai_responses_stream.ts +631 -0
- package/src/protocol/output/sse_stream.ts +102 -0
- package/src/providers/anthropic_safeguards.ts +115 -0
- package/src/providers/aws_assume_role.ts +147 -0
- package/src/providers/cancel_estimate.ts +140 -0
- package/src/providers/execution.ts +800 -0
- package/src/providers/model_capabilities.ts +50 -0
- package/src/providers/pi.ts +499 -0
- package/src/providers/pi_context.ts +466 -0
- package/src/providers/pi_options.ts +416 -0
- package/src/providers/pi_output.ts +500 -0
- package/src/providers/provider_attempt_runner.ts +153 -0
- package/src/providers/provider_call.ts +433 -0
- package/src/providers/provider_reported_cost.ts +138 -0
- package/src/providers/reasoning.ts +59 -0
- package/src/providers/response_capture.ts +65 -0
- package/src/providers/stream_finalization.ts +264 -0
- package/src/providers/subscription_limits.ts +208 -0
- package/src/providers/subscription_usage.ts +99 -0
- package/src/runtime/claude_code/auxiliary_routing.ts +80 -0
- package/src/runtime/customer_disconnect.ts +66 -0
- package/src/runtime/explicit_model.ts +86 -0
- package/src/runtime/harness_identity/conversation.ts +87 -0
- package/src/runtime/harness_identity/extract.ts +323 -0
- package/src/runtime/harness_identity/index.ts +14 -0
- package/src/runtime/prepare_routed_completion.ts +224 -0
- package/src/runtime/provider_stream_lifecycle.ts +105 -0
- package/src/runtime/provider_stream_outcome.ts +83 -0
- package/src/runtime/request_tags.ts +34 -0
- package/src/runtime/routed_completion.ts +102 -0
- package/src/runtime/routed_protocol.ts +33 -0
- package/src/runtime/routed_runtime.ts +722 -0
- package/src/runtime/routing_lease.ts +273 -0
- package/src/runtime/runtime_reporter.ts +115 -0
- package/src/self_host/config.ts +173 -0
- package/src/self_host/main.ts +104 -0
- package/src/self_host/platform.ts +63 -0
- package/src/self_host/prefix_store.ts +315 -0
- package/src/self_host/resolve.ts +240 -0
- package/src/self_host/subscriptions/access.ts +43 -0
- package/src/self_host/subscriptions/http.ts +204 -0
- package/src/self_host/subscriptions/index.ts +43 -0
- package/src/self_host/subscriptions/oauth.ts +395 -0
- package/src/self_host/subscriptions/store.ts +339 -0
- package/src/strategies/auto_routing_selector.ts +72 -0
- package/src/strategies/eval_relevance.ts +57 -0
- package/src/strategies/fallback_decision.ts +30 -0
- package/src/strategies/priority_selector.ts +40 -0
- package/src/strategies/route_decision.ts +106 -0
- package/src/strategies/routing.ts +455 -0
- package/src/strategies/selector_projection.ts +207 -0
- package/src/telemetry/background.ts +40 -0
- package/src/telemetry/benchmark_replay.ts +160 -0
- package/src/telemetry/billing_finalization.ts +167 -0
- package/src/telemetry/ingest_values.ts +15 -0
- package/src/telemetry/model_cost.ts +14 -0
- package/src/telemetry/prefix_state.ts +183 -0
- package/src/telemetry/report.ts +832 -0
- package/src/telemetry/requests/interaction_observability.ts +258 -0
- package/src/telemetry/requests/retained_interaction_request.ts +20 -0
- package/src/telemetry/scrub.ts +38 -0
- package/src/telemetry/skills/claude/catalog.ts +119 -0
- package/src/telemetry/skills/claude/file_reads.ts +153 -0
- package/src/telemetry/skills/claude/observations.ts +450 -0
- package/src/telemetry/skills/codex/catalog.ts +171 -0
- package/src/telemetry/skills/codex/exec_file_reads.ts +55 -0
- package/src/telemetry/skills/codex/explicit.ts +116 -0
- package/src/telemetry/skills/codex/file_reads.ts +51 -0
- package/src/telemetry/skills/codex/identity.ts +21 -0
- package/src/telemetry/skills/observations.ts +94 -0
- package/src/telemetry/skills/paths.ts +82 -0
- package/src/telemetry/skills/pi.ts +276 -0
- package/src/telemetry/skills/shell_file_reads.ts +267 -0
- package/src/telemetry/skills/shell_words.ts +202 -0
- package/src/telemetry/tools/input.ts +16 -0
- package/src/telemetry/tools/observations.ts +349 -0
- package/src/telemetry/usage.ts +173 -0
- package/src/types.ts +150 -0
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
import {
|
|
2
|
+
isRecord,
|
|
3
|
+
stableStringify,
|
|
4
|
+
type CandidatePromptAccounting,
|
|
5
|
+
} from "@mupt-ai/dari-router/policy-engine";
|
|
6
|
+
|
|
7
|
+
import { toolsByWireName } from "../interaction/tool_identity";
|
|
8
|
+
import type {
|
|
9
|
+
InteractionContent,
|
|
10
|
+
InteractionRequest,
|
|
11
|
+
ProviderIdentity,
|
|
12
|
+
} from "../interaction/types";
|
|
13
|
+
import { buildContext } from "../providers/pi_context";
|
|
14
|
+
import { isOpenAIResponsesApi } from "../providers/model_capabilities";
|
|
15
|
+
import type { InteractionPrefixContext } from "./interaction_fingerprint";
|
|
16
|
+
|
|
17
|
+
type PromptMetrics = {
|
|
18
|
+
blockDepth: number;
|
|
19
|
+
serializedChars: number;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
const ACCOUNTING_IMAGE_URL = "data:image/png;base64,AA==";
|
|
23
|
+
const IMAGE_ESTIMATED_TOKENS = {
|
|
24
|
+
low: 512,
|
|
25
|
+
auto: 1024,
|
|
26
|
+
high: 2048,
|
|
27
|
+
original: 2048,
|
|
28
|
+
} as const;
|
|
29
|
+
const CHARS_PER_ESTIMATED_TOKEN = 4;
|
|
30
|
+
|
|
31
|
+
// Cost and cache-lookback estimates are candidate-specific because compatible
|
|
32
|
+
// continuation state and provider serialization can change what each model
|
|
33
|
+
// sees.
|
|
34
|
+
export function providerPromptAccounting(args: {
|
|
35
|
+
request: InteractionRequest;
|
|
36
|
+
prefix: InteractionPrefixContext;
|
|
37
|
+
targetsByModel: ReadonlyMap<string, ProviderIdentity>;
|
|
38
|
+
}): CandidatePromptAccounting {
|
|
39
|
+
const fullByModel = new Map<string, PromptMetrics>();
|
|
40
|
+
const prefixCharsByModelAndItem = new Map<string, number>();
|
|
41
|
+
|
|
42
|
+
const targetFor = (model: string): ProviderIdentity => {
|
|
43
|
+
const target = args.targetsByModel.get(model);
|
|
44
|
+
if (!target) {
|
|
45
|
+
throw new Error(`missing provider target for ${model}`);
|
|
46
|
+
}
|
|
47
|
+
return target;
|
|
48
|
+
};
|
|
49
|
+
const fullMetrics = (model: string): PromptMetrics => {
|
|
50
|
+
const cached = fullByModel.get(model);
|
|
51
|
+
if (cached) return cached;
|
|
52
|
+
const target = targetFor(model);
|
|
53
|
+
const metrics = promptMetrics(args.request, target);
|
|
54
|
+
fullByModel.set(model, metrics);
|
|
55
|
+
return metrics;
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
return {
|
|
59
|
+
anchorSemantics: "stored_input",
|
|
60
|
+
providerBlockDepthFor: (model) => fullMetrics(model).blockDepth,
|
|
61
|
+
promptEstimateFor: (model, anchor) => {
|
|
62
|
+
const full = fullMetrics(model);
|
|
63
|
+
if (!anchor) {
|
|
64
|
+
return {
|
|
65
|
+
chars: full.serializedChars,
|
|
66
|
+
reusesStoredPromptTokens: false,
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const itemIndex = args.prefix.itemIndexByHashByModel
|
|
71
|
+
.get(model)
|
|
72
|
+
?.get(anchor.hash);
|
|
73
|
+
if (itemIndex === undefined) {
|
|
74
|
+
return {
|
|
75
|
+
chars: full.serializedChars,
|
|
76
|
+
reusesStoredPromptTokens: false,
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const key = `${model}\u0000${itemIndex}`;
|
|
81
|
+
let prefixChars = prefixCharsByModelAndItem.get(key);
|
|
82
|
+
if (prefixChars === undefined) {
|
|
83
|
+
prefixChars = promptMetrics(
|
|
84
|
+
{
|
|
85
|
+
...args.request,
|
|
86
|
+
items: args.request.items.slice(0, itemIndex + 1),
|
|
87
|
+
},
|
|
88
|
+
targetFor(model),
|
|
89
|
+
).serializedChars;
|
|
90
|
+
prefixCharsByModelAndItem.set(key, prefixChars);
|
|
91
|
+
}
|
|
92
|
+
return {
|
|
93
|
+
chars: Math.max(0, full.serializedChars - prefixChars),
|
|
94
|
+
reusesStoredPromptTokens: true,
|
|
95
|
+
};
|
|
96
|
+
},
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export function promptMetrics(
|
|
101
|
+
request: InteractionRequest,
|
|
102
|
+
target: ProviderIdentity,
|
|
103
|
+
): PromptMetrics {
|
|
104
|
+
const accountingInput = promptAccountingInput(request);
|
|
105
|
+
const context = buildContext(accountingInput.request, target);
|
|
106
|
+
const messages = context.messages.flatMap((message) => {
|
|
107
|
+
const visible = providerVisibleMessage(message);
|
|
108
|
+
return visible ? [visible] : [];
|
|
109
|
+
});
|
|
110
|
+
const tools = providerVisibleTools(request, context.tools, target);
|
|
111
|
+
const prompt = {
|
|
112
|
+
...(context.systemPrompt !== undefined
|
|
113
|
+
? { systemPrompt: context.systemPrompt }
|
|
114
|
+
: {}),
|
|
115
|
+
...(tools.length ? { tools } : {}),
|
|
116
|
+
messages,
|
|
117
|
+
...(request.responseFormat !== undefined
|
|
118
|
+
? { responseFormat: request.responseFormat }
|
|
119
|
+
: {}),
|
|
120
|
+
...(request.toolChoice !== undefined
|
|
121
|
+
? { toolChoice: request.toolChoice }
|
|
122
|
+
: {}),
|
|
123
|
+
...(request.parallelToolCalls !== undefined
|
|
124
|
+
? { parallelToolCalls: request.parallelToolCalls }
|
|
125
|
+
: {}),
|
|
126
|
+
};
|
|
127
|
+
return {
|
|
128
|
+
blockDepth: messages.reduce(
|
|
129
|
+
(total, message) => total + providerVisibleBlockCount(message),
|
|
130
|
+
0,
|
|
131
|
+
),
|
|
132
|
+
serializedChars:
|
|
133
|
+
stableStringify(prompt).length + accountingInput.imageEstimatedChars,
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function providerVisibleMessage(
|
|
138
|
+
value: unknown,
|
|
139
|
+
): Record<string, unknown> | null {
|
|
140
|
+
if (!isRecord(value) || typeof value.role !== "string") return null;
|
|
141
|
+
if (value.role === "assistant") {
|
|
142
|
+
return {
|
|
143
|
+
role: value.role,
|
|
144
|
+
content: sanitizeProviderContent(
|
|
145
|
+
Array.isArray(value.content) ? value.content : [],
|
|
146
|
+
),
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
if (value.role === "toolResult") {
|
|
150
|
+
return {
|
|
151
|
+
role: value.role,
|
|
152
|
+
toolCallId: value.toolCallId ?? null,
|
|
153
|
+
toolName: value.toolName ?? null,
|
|
154
|
+
content: sanitizeProviderContent(
|
|
155
|
+
Array.isArray(value.content) ? value.content : [],
|
|
156
|
+
),
|
|
157
|
+
isError: value.isError === true,
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
return {
|
|
161
|
+
role: value.role,
|
|
162
|
+
content: sanitizeProviderContent(value.content ?? ""),
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function sanitizeProviderContent(value: unknown): unknown {
|
|
167
|
+
if (!Array.isArray(value)) return value;
|
|
168
|
+
return value.map((part) => {
|
|
169
|
+
if (!isRecord(part)) return part;
|
|
170
|
+
if (part.type === "providerItem" && isRecord(part.item)) {
|
|
171
|
+
return part.item;
|
|
172
|
+
}
|
|
173
|
+
if (
|
|
174
|
+
part.type === "image"
|
|
175
|
+
&& typeof part.mimeType === "string"
|
|
176
|
+
&& typeof part.data === "string"
|
|
177
|
+
) {
|
|
178
|
+
return { ...part, data: "<image-data>" };
|
|
179
|
+
}
|
|
180
|
+
return part;
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function providerVisibleBlockCount(
|
|
185
|
+
message: Record<string, unknown>,
|
|
186
|
+
): number {
|
|
187
|
+
if (message.role === "toolResult") return 1;
|
|
188
|
+
const content = message.content;
|
|
189
|
+
return Math.max(1, Array.isArray(content) ? content.length : 1);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function providerVisibleTools(
|
|
193
|
+
request: InteractionRequest,
|
|
194
|
+
contextTools: Array<Record<string, unknown>> | undefined,
|
|
195
|
+
target: ProviderIdentity,
|
|
196
|
+
): unknown[] {
|
|
197
|
+
const definitions = toolsByWireName(request.tools);
|
|
198
|
+
const tools = (contextTools ?? []).map((tool) => {
|
|
199
|
+
if (typeof tool.name !== "string") return tool;
|
|
200
|
+
const definition = definitions.get(tool.name);
|
|
201
|
+
return definition?.kind !== "function"
|
|
202
|
+
|| definition.strict === undefined
|
|
203
|
+
? tool
|
|
204
|
+
: { ...tool, strict: definition.strict };
|
|
205
|
+
});
|
|
206
|
+
if (!isOpenAIResponsesApi(target.api)) return tools;
|
|
207
|
+
return [
|
|
208
|
+
...tools,
|
|
209
|
+
...request.tools.flatMap((tool) =>
|
|
210
|
+
tool.kind === "hosted" ? [tool.config] : []
|
|
211
|
+
),
|
|
212
|
+
];
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function promptAccountingInput(request: InteractionRequest): {
|
|
216
|
+
request: InteractionRequest;
|
|
217
|
+
imageEstimatedChars: number;
|
|
218
|
+
} {
|
|
219
|
+
let imageEstimatedChars = 0;
|
|
220
|
+
const sanitizeContent = (parts: InteractionContent[]) =>
|
|
221
|
+
parts.map((part) => {
|
|
222
|
+
if (part.type !== "image") return part;
|
|
223
|
+
const detail = part.detail ?? "auto";
|
|
224
|
+
imageEstimatedChars +=
|
|
225
|
+
IMAGE_ESTIMATED_TOKENS[detail] * CHARS_PER_ESTIMATED_TOKEN;
|
|
226
|
+
return { ...part, url: ACCOUNTING_IMAGE_URL };
|
|
227
|
+
});
|
|
228
|
+
const items = request.items.map((item) => {
|
|
229
|
+
if (item.type === "message") {
|
|
230
|
+
return { ...item, content: sanitizeContent(item.content) };
|
|
231
|
+
}
|
|
232
|
+
if (item.type === "tool_result" && Array.isArray(item.output)) {
|
|
233
|
+
return { ...item, output: sanitizeContent(item.output) };
|
|
234
|
+
}
|
|
235
|
+
return item;
|
|
236
|
+
});
|
|
237
|
+
return {
|
|
238
|
+
request: { ...request, items },
|
|
239
|
+
imageEstimatedChars,
|
|
240
|
+
};
|
|
241
|
+
}
|
|
@@ -0,0 +1,337 @@
|
|
|
1
|
+
import {
|
|
2
|
+
prepareCandidates,
|
|
3
|
+
type ActiveLease,
|
|
4
|
+
type PendingLease,
|
|
5
|
+
type CandidateCostEstimate,
|
|
6
|
+
type CandidateModelMetadata,
|
|
7
|
+
type ChainHits,
|
|
8
|
+
type PrefixHit,
|
|
9
|
+
type ChatMessage,
|
|
10
|
+
type HardCapability,
|
|
11
|
+
type PreviousDecision,
|
|
12
|
+
type PricingLookup,
|
|
13
|
+
type SelectorLeaseHistoryEntry,
|
|
14
|
+
type StrategyCandidateResolution,
|
|
15
|
+
} from "@mupt-ai/dari-router/policy-engine";
|
|
16
|
+
import { randomUUID } from "node:crypto";
|
|
17
|
+
|
|
18
|
+
import { HttpError } from "../http/errors";
|
|
19
|
+
import { effectiveContextWindow } from "../models/context_limits";
|
|
20
|
+
import { keySourceForModel } from "../models/execution_provider";
|
|
21
|
+
import { requiredCapabilitiesFor } from "../interaction/capabilities";
|
|
22
|
+
import { providerIdentityFromModel } from "../interaction/provider_continuation_state";
|
|
23
|
+
import { isBackgroundRequest, type InteractionRequest, type ProviderIdentity } from "../interaction/types";
|
|
24
|
+
import { requireCandidateModels } from "../models/ids";
|
|
25
|
+
import { modelMetadataLookup } from "../providers/pi";
|
|
26
|
+
import {
|
|
27
|
+
modelSupportsImageInput,
|
|
28
|
+
modelSupportsHostedWebSearch,
|
|
29
|
+
modelSupportsReasoningConfigurationUpdate,
|
|
30
|
+
providerApiCanSerializeResponseFormat,
|
|
31
|
+
} from "../providers/model_capabilities";
|
|
32
|
+
import {
|
|
33
|
+
selectorMessagesForInteraction,
|
|
34
|
+
type SelectorImage,
|
|
35
|
+
} from "../strategies/selector_projection";
|
|
36
|
+
import type { ResolvedRouter } from "../types";
|
|
37
|
+
import {
|
|
38
|
+
interactionPrefixContext,
|
|
39
|
+
interactionPrefixLookupHashes,
|
|
40
|
+
type InteractionPrefixContext,
|
|
41
|
+
} from "./interaction_fingerprint";
|
|
42
|
+
import { pricingLookupForRouter } from "./pricing";
|
|
43
|
+
import { providerPromptAccounting } from "./provider_prompt_accounting";
|
|
44
|
+
import { leaseHistoryFromPrefixHits } from "./lease_history";
|
|
45
|
+
import { interactionLeaseFenceDepth } from "./interaction_lease_depth";
|
|
46
|
+
|
|
47
|
+
export type ImageCapabilityPrunedModel = {
|
|
48
|
+
model: string;
|
|
49
|
+
reason: "image_input_unsupported";
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
export type RoutingContext = {
|
|
53
|
+
// Target-independent request depth used only to fence asynchronous lease
|
|
54
|
+
// writes. Provider cache lookback uses providerBlockDepthFor instead.
|
|
55
|
+
leaseDepth: number;
|
|
56
|
+
hits: ChainHits;
|
|
57
|
+
reportingCostEstimates: CandidateCostEstimate[];
|
|
58
|
+
previousDecision?: PreviousDecision;
|
|
59
|
+
// Unexpired lease commitment recovered from this conversation's prefix
|
|
60
|
+
// chain; the runtime serves it without consulting the selector.
|
|
61
|
+
activeLease?: ActiveLease;
|
|
62
|
+
// Next lease prefetched before expiry; adopted when no lease is active.
|
|
63
|
+
pendingLease?: PendingLease;
|
|
64
|
+
pricing: PricingLookup;
|
|
65
|
+
conversationId: string;
|
|
66
|
+
isNewConversation: boolean;
|
|
67
|
+
// Depth of the identity hit that recovered the conversation id (strict or
|
|
68
|
+
// loose tier, warm or merely recent); null for a fresh conversation.
|
|
69
|
+
identityMatchDepth: number | null;
|
|
70
|
+
identityHit?: PrefixHit;
|
|
71
|
+
zdr: boolean;
|
|
72
|
+
candidateResolution: StrategyCandidateResolution;
|
|
73
|
+
// Models excluded before strategy resolution because the pending request
|
|
74
|
+
// carries image content they cannot accept; absent when nothing was pruned.
|
|
75
|
+
imageCapabilityPrunedModels?: ImageCapabilityPrunedModel[];
|
|
76
|
+
prefix: InteractionPrefixContext;
|
|
77
|
+
targetsByModel: ReadonlyMap<string, ProviderIdentity>;
|
|
78
|
+
selectorMessages: ChatMessage[];
|
|
79
|
+
// Images referenced by `<image N>` placeholders in selectorMessages; empty
|
|
80
|
+
// unless the router opted into selector image input.
|
|
81
|
+
selectorImages: SelectorImage[];
|
|
82
|
+
// Absent when the router has lease context disabled: no reconstruction runs
|
|
83
|
+
// and downstream selector inputs must stay in the pre-lease-context shape.
|
|
84
|
+
leaseHistory?: SelectorLeaseHistoryEntry[];
|
|
85
|
+
providerBlockDepthFor: (model: string) => number;
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
// Router resolution only needs lookup hashes. Model-specific chains are
|
|
89
|
+
// derived after resolve, when the enabled providers and APIs are known.
|
|
90
|
+
export function derivePrefixLookupHashes(
|
|
91
|
+
routerId: string,
|
|
92
|
+
interaction: InteractionRequest,
|
|
93
|
+
): string[] {
|
|
94
|
+
try {
|
|
95
|
+
return interactionPrefixLookupHashes(routerId, interaction);
|
|
96
|
+
} catch (error) {
|
|
97
|
+
console.error("prefix fingerprinting failed", error);
|
|
98
|
+
return [];
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export async function buildRoutingContext(args: {
|
|
103
|
+
routerId: string;
|
|
104
|
+
interaction: InteractionRequest;
|
|
105
|
+
credentials: ResolvedRouter;
|
|
106
|
+
previousDecision?: PreviousDecision;
|
|
107
|
+
explicitConversationId?: string;
|
|
108
|
+
// Warmth freshness is measured from request arrival; callers without a
|
|
109
|
+
// request clock (experiments) default to now.
|
|
110
|
+
receivedAt?: Date;
|
|
111
|
+
}): Promise<RoutingContext> {
|
|
112
|
+
const configuredCandidateModels = requireCandidateModels(
|
|
113
|
+
args.credentials.enabled_models,
|
|
114
|
+
);
|
|
115
|
+
// With credits exhausted, route only among models a personal subscription
|
|
116
|
+
// serves. The credit gate refuses routers with none.
|
|
117
|
+
const fundedCandidateModels = args.credentials.credit_exhausted
|
|
118
|
+
? configuredCandidateModels.filter((model) => keySourceForModel(args.credentials, model) === "subscription")
|
|
119
|
+
: configuredCandidateModels;
|
|
120
|
+
const { messages: selectorMessages, images: selectorImages } =
|
|
121
|
+
selectorMessagesForInteraction(args.interaction, {
|
|
122
|
+
imageInput: args.credentials.selector_image_input_enabled === true,
|
|
123
|
+
});
|
|
124
|
+
const leaseDepth = interactionLeaseFenceDepth(args.interaction);
|
|
125
|
+
const metadataLookup = modelMetadataLookup(args.credentials.model_configs);
|
|
126
|
+
const targetsByModel = new Map<string, ProviderIdentity>();
|
|
127
|
+
for (const model of configuredCandidateModels) {
|
|
128
|
+
targetsByModel.set(
|
|
129
|
+
model,
|
|
130
|
+
providerIdentityFromModel(metadataLookup(model).model),
|
|
131
|
+
);
|
|
132
|
+
}
|
|
133
|
+
const capabilityLookup = (modelId: string): CandidateModelMetadata => {
|
|
134
|
+
const metadata = metadataLookup(modelId);
|
|
135
|
+
return {
|
|
136
|
+
provider: typeof metadata.model.provider === "string" ? metadata.model.provider : "",
|
|
137
|
+
api: typeof metadata.model.api === "string" ? metadata.model.api : "",
|
|
138
|
+
supportsImageInput: modelSupportsImageInput(metadata),
|
|
139
|
+
supportsHostedWebSearch: modelSupportsHostedWebSearch(metadata),
|
|
140
|
+
supportsReasoningConfigurationUpdate: modelSupportsReasoningConfigurationUpdate(metadata),
|
|
141
|
+
supportsStructuredOutput: providerApiCanSerializeResponseFormat(metadata),
|
|
142
|
+
supportedThinkingLevels: metadata.supportedThinkingLevels,
|
|
143
|
+
};
|
|
144
|
+
};
|
|
145
|
+
// Image capability filtering (router setting, absent = ON) narrows the
|
|
146
|
+
// candidate set before strategy resolution — an image-bearing conversation
|
|
147
|
+
// must not route (or hold a lease) to a text-only model. Prefix chains and
|
|
148
|
+
// provider accounting still cover every configured model.
|
|
149
|
+
const imageFilter = resolveImageCapableCandidates({
|
|
150
|
+
candidateModels: fundedCandidateModels.length > 0 ? fundedCandidateModels : configuredCandidateModels,
|
|
151
|
+
requiredCapabilities: requiredCapabilitiesFor(args.interaction),
|
|
152
|
+
enabled: args.credentials.image_capability_filtering !== false,
|
|
153
|
+
supportsImageInput: (model) => capabilityLookup(model).supportsImageInput,
|
|
154
|
+
});
|
|
155
|
+
const prefix = derivePrefixContext({
|
|
156
|
+
routerId: args.routerId,
|
|
157
|
+
interaction: args.interaction,
|
|
158
|
+
targetsByModel,
|
|
159
|
+
});
|
|
160
|
+
const promptAccounting = providerPromptAccounting({
|
|
161
|
+
request: args.interaction,
|
|
162
|
+
prefix,
|
|
163
|
+
targetsByModel,
|
|
164
|
+
});
|
|
165
|
+
const pricing = pricingLookupForRouter(args.credentials);
|
|
166
|
+
// A validated harness identity is authoritative. Matching text from another
|
|
167
|
+
// thread must not contribute cache warmth or selector continuity.
|
|
168
|
+
const background = isBackgroundRequest(args.interaction);
|
|
169
|
+
const prefixHits = (background ? [] : args.credentials.prefix_hits ?? []).filter(
|
|
170
|
+
(hit) => args.explicitConversationId === undefined
|
|
171
|
+
|| hit.conversation_id === args.explicitConversationId,
|
|
172
|
+
);
|
|
173
|
+
const prepared = prepareCandidates({
|
|
174
|
+
candidateModels: imageFilter.candidateModels,
|
|
175
|
+
metadataLookup: capabilityLookup,
|
|
176
|
+
requiredCapabilities: imageFilter.requiredCapabilities,
|
|
177
|
+
requestedReasoningEffort: args.interaction.reasoning?.effort,
|
|
178
|
+
thinkingEnabled: args.interaction.reasoning?.enabled === true,
|
|
179
|
+
toolChoice: args.interaction.toolChoice,
|
|
180
|
+
modelThinkingLevels: args.credentials.model_thinking_levels,
|
|
181
|
+
strategy: args.credentials.routing_strategy === "custom" ? "custom" : "dari",
|
|
182
|
+
customConfig: args.credentials.custom_config,
|
|
183
|
+
modelPrices: args.credentials.model_prices ?? {},
|
|
184
|
+
pricing,
|
|
185
|
+
promptTokenLimitFor: (model) => effectiveContextWindow(
|
|
186
|
+
args.credentials.model_configs?.[model],
|
|
187
|
+
args.credentials.allow_long_context === true,
|
|
188
|
+
),
|
|
189
|
+
requiresPromptEstimateFor: (model) => args.credentials.allow_long_context !== true
|
|
190
|
+
&& args.credentials.model_configs?.[model]?.standard_context_limit != null,
|
|
191
|
+
averageOutputTokensByModel: Object.fromEntries(
|
|
192
|
+
Object.entries(args.credentials.model_configs ?? {}).map(([model, config]) => [
|
|
193
|
+
model,
|
|
194
|
+
config.average_output_tokens ?? null,
|
|
195
|
+
]),
|
|
196
|
+
),
|
|
197
|
+
promptAccounting,
|
|
198
|
+
chainsByModel: prefix.chainByModel,
|
|
199
|
+
// Resolve returns hits up to the platform's conversation lookback;
|
|
200
|
+
// core narrows them to the warmth window for cost estimation and keeps the
|
|
201
|
+
// rest for conversation identity and the previous decision.
|
|
202
|
+
prefixHits,
|
|
203
|
+
nowMs: (args.receivedAt ?? new Date()).getTime(),
|
|
204
|
+
looseChain: prefix.looseChain,
|
|
205
|
+
conversationId: args.explicitConversationId,
|
|
206
|
+
toolChoiceFp: prefix.fingerprints.tool_choice_fp,
|
|
207
|
+
responseFormatFp: prefix.fingerprints.response_format_fp,
|
|
208
|
+
previousDecision: background ? undefined : args.previousDecision,
|
|
209
|
+
});
|
|
210
|
+
for (const warning of prepared.warnings) {
|
|
211
|
+
console.error("cache cost estimation failed", warning.error);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
const conversationId = args.explicitConversationId
|
|
215
|
+
?? prepared.conversationId
|
|
216
|
+
?? randomUUID();
|
|
217
|
+
// A harness identity always produces an explicit conversation id, so it
|
|
218
|
+
// cannot distinguish a brand-new session from a recovered one on its own.
|
|
219
|
+
// Prefix lookup is already scoped to that id; no recovered conversation
|
|
220
|
+
// therefore means this is its first observed turn.
|
|
221
|
+
const isNewConversation = prepared.conversationId === null;
|
|
222
|
+
// Lease context (router setting, absent = ON) feeds the selector the
|
|
223
|
+
// original task and reconstructed lease history. Disabled routers skip the
|
|
224
|
+
// reconstruction entirely so their selector inputs stay byte-identical to
|
|
225
|
+
// the pre-lease-context behavior.
|
|
226
|
+
const leaseHistory = !background && args.credentials.lease_context_enabled !== false
|
|
227
|
+
? leaseHistoryFromPrefixHits({
|
|
228
|
+
prefixHits,
|
|
229
|
+
conversationId,
|
|
230
|
+
candidates: prepared.candidateResolution.candidates,
|
|
231
|
+
messages: selectorMessages,
|
|
232
|
+
})
|
|
233
|
+
: undefined;
|
|
234
|
+
return {
|
|
235
|
+
leaseDepth,
|
|
236
|
+
hits: prepared.hits,
|
|
237
|
+
reportingCostEstimates: prepared.costEstimates,
|
|
238
|
+
...(prepared.previousDecision !== undefined
|
|
239
|
+
? { previousDecision: prepared.previousDecision }
|
|
240
|
+
: {}),
|
|
241
|
+
...(prepared.activeLease !== undefined ? { activeLease: prepared.activeLease } : {}),
|
|
242
|
+
...(prepared.pendingLease !== undefined ? { pendingLease: prepared.pendingLease } : {}),
|
|
243
|
+
pricing,
|
|
244
|
+
conversationId,
|
|
245
|
+
isNewConversation,
|
|
246
|
+
identityMatchDepth: prepared.identityMatchDepth,
|
|
247
|
+
...(prepared.identityHit !== undefined
|
|
248
|
+
? { identityHit: prepared.identityHit }
|
|
249
|
+
: {}),
|
|
250
|
+
zdr: args.credentials.zdr === true,
|
|
251
|
+
candidateResolution: prepared.candidateResolution,
|
|
252
|
+
...(imageFilter.prunedModels.length > 0
|
|
253
|
+
? { imageCapabilityPrunedModels: imageFilter.prunedModels }
|
|
254
|
+
: {}),
|
|
255
|
+
prefix,
|
|
256
|
+
targetsByModel,
|
|
257
|
+
selectorMessages,
|
|
258
|
+
selectorImages,
|
|
259
|
+
...(leaseHistory !== undefined ? { leaseHistory } : {}),
|
|
260
|
+
providerBlockDepthFor: promptAccounting.providerBlockDepthFor,
|
|
261
|
+
};
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// When the filter is disabled, "image_input" is also dropped from the hard
|
|
265
|
+
// capabilities so core preparation keeps today's pass-through behavior instead
|
|
266
|
+
// of re-applying the same exclusion. When enabled, the capability stays as a
|
|
267
|
+
// core-side invariant over the already-narrowed set.
|
|
268
|
+
function resolveImageCapableCandidates(args: {
|
|
269
|
+
candidateModels: string[];
|
|
270
|
+
requiredCapabilities: HardCapability[];
|
|
271
|
+
enabled: boolean;
|
|
272
|
+
supportsImageInput: (model: string) => boolean;
|
|
273
|
+
}): {
|
|
274
|
+
candidateModels: string[];
|
|
275
|
+
requiredCapabilities: HardCapability[];
|
|
276
|
+
prunedModels: ImageCapabilityPrunedModel[];
|
|
277
|
+
} {
|
|
278
|
+
if (!args.requiredCapabilities.includes("image_input")) {
|
|
279
|
+
return {
|
|
280
|
+
candidateModels: args.candidateModels,
|
|
281
|
+
requiredCapabilities: args.requiredCapabilities,
|
|
282
|
+
prunedModels: [],
|
|
283
|
+
};
|
|
284
|
+
}
|
|
285
|
+
if (!args.enabled) {
|
|
286
|
+
return {
|
|
287
|
+
candidateModels: args.candidateModels,
|
|
288
|
+
requiredCapabilities: args.requiredCapabilities.filter(
|
|
289
|
+
(capability) => capability !== "image_input",
|
|
290
|
+
),
|
|
291
|
+
prunedModels: [],
|
|
292
|
+
};
|
|
293
|
+
}
|
|
294
|
+
const capable = args.candidateModels.filter(args.supportsImageInput);
|
|
295
|
+
if (capable.length === 0) {
|
|
296
|
+
throw new HttpError(
|
|
297
|
+
400,
|
|
298
|
+
"This conversation contains image input, but no enabled model on this router accepts images.",
|
|
299
|
+
"no_image_capable_candidates",
|
|
300
|
+
);
|
|
301
|
+
}
|
|
302
|
+
return {
|
|
303
|
+
candidateModels: capable,
|
|
304
|
+
requiredCapabilities: args.requiredCapabilities,
|
|
305
|
+
prunedModels: args.candidateModels
|
|
306
|
+
.filter((model) => !args.supportsImageInput(model))
|
|
307
|
+
.map((model) => ({ model, reason: "image_input_unsupported" as const })),
|
|
308
|
+
};
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
function derivePrefixContext(args: {
|
|
312
|
+
routerId: string;
|
|
313
|
+
interaction: InteractionRequest;
|
|
314
|
+
targetsByModel: ReadonlyMap<string, ProviderIdentity>;
|
|
315
|
+
}): InteractionPrefixContext {
|
|
316
|
+
try {
|
|
317
|
+
return interactionPrefixContext(
|
|
318
|
+
args.routerId,
|
|
319
|
+
args.interaction,
|
|
320
|
+
args.targetsByModel,
|
|
321
|
+
);
|
|
322
|
+
} catch (error) {
|
|
323
|
+
console.error("prefix fingerprinting failed", error);
|
|
324
|
+
return {
|
|
325
|
+
chainByModel: new Map(
|
|
326
|
+
[...args.targetsByModel].map(([model]) => [model, []]),
|
|
327
|
+
),
|
|
328
|
+
itemIndexByHashByModel: new Map(
|
|
329
|
+
[...args.targetsByModel].map(
|
|
330
|
+
([model]) => [model, new Map<string, number>()],
|
|
331
|
+
),
|
|
332
|
+
),
|
|
333
|
+
looseChain: [],
|
|
334
|
+
fingerprints: { tool_choice_fp: "", response_format_fp: "" },
|
|
335
|
+
};
|
|
336
|
+
}
|
|
337
|
+
}
|
package/src/http/auth.ts
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { timingSafeEqual } from "node:crypto";
|
|
2
|
+
|
|
3
|
+
import { HttpError } from "./errors";
|
|
4
|
+
|
|
5
|
+
export function bearerToken(headers: Headers): string {
|
|
6
|
+
const value = headers.get("authorization") ?? "";
|
|
7
|
+
const [scheme, token] = value.split(/\s+/, 2);
|
|
8
|
+
if (scheme?.toLowerCase() !== "bearer" || !token) {
|
|
9
|
+
throw new HttpError(401, "Missing or invalid routing API key.", "authentication_error");
|
|
10
|
+
}
|
|
11
|
+
return publicRoutingToken(token);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function anthropicToken(headers: Headers): string {
|
|
15
|
+
const authorization = headers.get("authorization");
|
|
16
|
+
if (authorization) return bearerToken(headers);
|
|
17
|
+
const apiKey = headers.get("x-api-key");
|
|
18
|
+
if (!apiKey) {
|
|
19
|
+
throw new HttpError(401, "Missing or invalid routing API key.", "authentication_error");
|
|
20
|
+
}
|
|
21
|
+
return publicRoutingToken(apiKey);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function publicRoutingToken(token: string): string {
|
|
25
|
+
const internalToken = process.env.DARI_ROUTING_INTERNAL_TOKEN;
|
|
26
|
+
if (internalToken && matchesInternalToken(token, internalToken)) {
|
|
27
|
+
throw new HttpError(
|
|
28
|
+
401,
|
|
29
|
+
"Internal routing credentials cannot authorize public inference.",
|
|
30
|
+
"authentication_error",
|
|
31
|
+
);
|
|
32
|
+
}
|
|
33
|
+
return token;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function matchesInternalToken(token: string, internalToken: string): boolean {
|
|
37
|
+
// Compare byte lengths, not character counts: timingSafeEqual throws on
|
|
38
|
+
// buffers of different sizes, and a multi-byte character can match the
|
|
39
|
+
// secret's character count without matching its byte length.
|
|
40
|
+
const candidate = Buffer.from(token);
|
|
41
|
+
const expected = Buffer.from(internalToken);
|
|
42
|
+
return candidate.length === expected.length && timingSafeEqual(candidate, expected);
|
|
43
|
+
}
|