@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,189 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
|
|
3
|
+
import { stableStringify } from "@mupt-ai/dari-router/policy-engine";
|
|
4
|
+
|
|
5
|
+
import type {
|
|
6
|
+
InteractionContent,
|
|
7
|
+
InteractionRequest,
|
|
8
|
+
} from "../interaction/types";
|
|
9
|
+
|
|
10
|
+
// Bump when canonical fingerprint semantics change so persisted hashes from
|
|
11
|
+
// incompatible contracts remain disjoint.
|
|
12
|
+
const INTERACTION_FINGERPRINT_VERSION = "interaction-v3";
|
|
13
|
+
|
|
14
|
+
const OPTION_FINGERPRINT_VERSION = "interaction-options-v1";
|
|
15
|
+
|
|
16
|
+
export type FingerprintContent =
|
|
17
|
+
| string
|
|
18
|
+
| Array<
|
|
19
|
+
| { type: "text"; text: string }
|
|
20
|
+
| {
|
|
21
|
+
type: "image";
|
|
22
|
+
url: string;
|
|
23
|
+
detail?: "auto" | "low" | "high" | "original";
|
|
24
|
+
}
|
|
25
|
+
| { type: "tool_reference"; toolName: string }
|
|
26
|
+
>
|
|
27
|
+
| null;
|
|
28
|
+
|
|
29
|
+
export type FingerprintCall = {
|
|
30
|
+
id: string;
|
|
31
|
+
kind: "function" | "custom" | "hosted";
|
|
32
|
+
name: string;
|
|
33
|
+
input: unknown;
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
export type ReasoningFingerprint = {
|
|
37
|
+
itemId?: string;
|
|
38
|
+
readableSha256: string;
|
|
39
|
+
continuationStateSha256?: string;
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
export type InteractionFingerprintStep = {
|
|
43
|
+
actor: "system" | "developer" | "user" | "assistant" | "tool";
|
|
44
|
+
content: FingerprintContent;
|
|
45
|
+
calls: FingerprintCall[];
|
|
46
|
+
resultCallId: string | null;
|
|
47
|
+
itemIndex: number;
|
|
48
|
+
itemId?: string;
|
|
49
|
+
phase?: "commentary" | "final_answer";
|
|
50
|
+
reasoning?: ReasoningFingerprint[];
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
export function interactionFingerprintHeadHash(
|
|
54
|
+
routerId: string,
|
|
55
|
+
request: InteractionRequest,
|
|
56
|
+
): string {
|
|
57
|
+
const systemItems: Array<{ actor: "system" | "developer"; content: FingerprintContent }> = [];
|
|
58
|
+
for (const item of request.items) {
|
|
59
|
+
if (
|
|
60
|
+
item.type !== "message"
|
|
61
|
+
|| (item.role !== "system" && item.role !== "developer")
|
|
62
|
+
) {
|
|
63
|
+
break;
|
|
64
|
+
}
|
|
65
|
+
systemItems.push({
|
|
66
|
+
actor: item.role,
|
|
67
|
+
content: systemFingerprintContent(item.content, item.contentShape),
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
return sha256(stableStringify({
|
|
71
|
+
version: INTERACTION_FINGERPRINT_VERSION,
|
|
72
|
+
routerId,
|
|
73
|
+
...(request.agentContext === undefined ? {} : { agentContext: {
|
|
74
|
+
requestClass: request.agentContext.requestClass,
|
|
75
|
+
agentId: request.agentContext.agentId ?? null,
|
|
76
|
+
} }),
|
|
77
|
+
instructions: request.instructions ?? null,
|
|
78
|
+
systemItems,
|
|
79
|
+
tools: request.tools,
|
|
80
|
+
}));
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function systemFingerprintContent(
|
|
84
|
+
content: string | InteractionContent[] | null,
|
|
85
|
+
shape: "string" | "parts" | "null" | "absent",
|
|
86
|
+
): FingerprintContent {
|
|
87
|
+
const normalized = interactionFingerprintContent(content, shape);
|
|
88
|
+
// Anthropic defines a string system value and one plain text block as
|
|
89
|
+
// equivalent. Preserve that established head identity without flattening
|
|
90
|
+
// structured system content such as images or multiple blocks.
|
|
91
|
+
if (
|
|
92
|
+
Array.isArray(normalized)
|
|
93
|
+
&& normalized.length === 1
|
|
94
|
+
&& normalized[0]?.type === "text"
|
|
95
|
+
) {
|
|
96
|
+
return normalized[0].text;
|
|
97
|
+
}
|
|
98
|
+
return normalized;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export function interactionOptionsFingerprints(
|
|
102
|
+
request: InteractionRequest,
|
|
103
|
+
): { tool_choice_fp: string; response_format_fp: string } {
|
|
104
|
+
return {
|
|
105
|
+
tool_choice_fp: optionFingerprint("tool-choice", request.toolChoice ?? null),
|
|
106
|
+
response_format_fp: optionFingerprint(
|
|
107
|
+
"response-format",
|
|
108
|
+
request.responseFormat ?? null,
|
|
109
|
+
),
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export function canonicalInteractionFingerprintStep(
|
|
114
|
+
step: InteractionFingerprintStep,
|
|
115
|
+
): string {
|
|
116
|
+
return stableStringify({
|
|
117
|
+
actor: step.actor,
|
|
118
|
+
content: step.content,
|
|
119
|
+
calls: step.calls.length > 0 ? step.calls : null,
|
|
120
|
+
resultCallId: step.resultCallId,
|
|
121
|
+
...(step.itemId ? { itemId: step.itemId } : {}),
|
|
122
|
+
...(step.phase ? { phase: step.phase } : {}),
|
|
123
|
+
...(step.reasoning ? { reasoning: step.reasoning } : {}),
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export function interactionFingerprintContent(
|
|
128
|
+
content: string | InteractionContent[] | null,
|
|
129
|
+
shape: "string" | "parts" | "null" | "absent",
|
|
130
|
+
): FingerprintContent {
|
|
131
|
+
// Preserve the established identity rule that absent and explicit null
|
|
132
|
+
// content are equivalent. Structured parts remain distinct from strings.
|
|
133
|
+
if (shape === "absent" || shape === "null") return null;
|
|
134
|
+
if (shape === "string") {
|
|
135
|
+
if (typeof content === "string") return content;
|
|
136
|
+
if (!Array.isArray(content)) return "";
|
|
137
|
+
return content
|
|
138
|
+
.filter((part): part is { type: "text"; text: string } =>
|
|
139
|
+
part.type === "text"
|
|
140
|
+
)
|
|
141
|
+
.map((part) => part.text)
|
|
142
|
+
.join("");
|
|
143
|
+
}
|
|
144
|
+
if (!Array.isArray(content)) return [];
|
|
145
|
+
return content.map((part) => {
|
|
146
|
+
if (part.type === "text") return { type: "text" as const, text: part.text };
|
|
147
|
+
if (part.type === "tool_reference") {
|
|
148
|
+
return {
|
|
149
|
+
type: "tool_reference" as const,
|
|
150
|
+
toolName: part.toolName,
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
return {
|
|
154
|
+
type: "image" as const,
|
|
155
|
+
url: part.url,
|
|
156
|
+
...(part.detail !== undefined ? { detail: part.detail } : {}),
|
|
157
|
+
};
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
export function normalizedAssistantFingerprintContent(
|
|
162
|
+
content: string | InteractionContent[] | null,
|
|
163
|
+
shape: "string" | "parts" | "null" | "absent",
|
|
164
|
+
): FingerprintContent {
|
|
165
|
+
const normalized = interactionFingerprintContent(content, shape);
|
|
166
|
+
return typeof normalized === "string" && normalized.trim() === ""
|
|
167
|
+
? null
|
|
168
|
+
: normalized;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
export function appendFingerprintContentText(
|
|
172
|
+
content: FingerprintContent,
|
|
173
|
+
text: string,
|
|
174
|
+
): FingerprintContent {
|
|
175
|
+
if (!text) return content;
|
|
176
|
+
if (typeof content === "string") return content ? `${content}\n${text}` : text;
|
|
177
|
+
if (Array.isArray(content)) return [...content, { type: "text", text }];
|
|
178
|
+
return text;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function optionFingerprint(kind: string, value: unknown): string {
|
|
182
|
+
return sha256(
|
|
183
|
+
`${OPTION_FINGERPRINT_VERSION}\u0000${kind}\u0000${stableStringify(value)}`,
|
|
184
|
+
);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function sha256(text: string): string {
|
|
188
|
+
return createHash("sha256").update(text, "utf8").digest("hex");
|
|
189
|
+
}
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
InteractionContent,
|
|
3
|
+
InteractionMessage,
|
|
4
|
+
InteractionRequest,
|
|
5
|
+
} from "../interaction/types";
|
|
6
|
+
|
|
7
|
+
type AssistantGroup = {
|
|
8
|
+
kind: "assistant";
|
|
9
|
+
rawBlocks: number;
|
|
10
|
+
hasContent: boolean;
|
|
11
|
+
hasReasoning: boolean;
|
|
12
|
+
hasToolCalls: boolean;
|
|
13
|
+
hostedToolCarrier: boolean;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
type LastGroup = AssistantGroup | { kind: "other" } | null;
|
|
17
|
+
|
|
18
|
+
// Monotonic, target-independent position used only to fence asynchronous
|
|
19
|
+
// lease writes. It follows canonical Interaction grouping and deliberately
|
|
20
|
+
// does not serialize selector messages or approximate provider cache blocks.
|
|
21
|
+
export function interactionLeaseFenceDepth(
|
|
22
|
+
request: InteractionRequest,
|
|
23
|
+
): number {
|
|
24
|
+
const state: { depth: number; last: LastGroup } = {
|
|
25
|
+
depth: 0,
|
|
26
|
+
last: null,
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
const startAssistant = (args: {
|
|
30
|
+
rawBlocks: number;
|
|
31
|
+
hasContent?: boolean;
|
|
32
|
+
hasReasoning?: boolean;
|
|
33
|
+
hasToolCalls?: boolean;
|
|
34
|
+
hostedToolCarrier?: boolean;
|
|
35
|
+
}): AssistantGroup => {
|
|
36
|
+
const assistant: AssistantGroup = {
|
|
37
|
+
kind: "assistant",
|
|
38
|
+
rawBlocks: args.rawBlocks,
|
|
39
|
+
hasContent: args.hasContent ?? false,
|
|
40
|
+
hasReasoning: args.hasReasoning ?? false,
|
|
41
|
+
hasToolCalls: args.hasToolCalls ?? false,
|
|
42
|
+
hostedToolCarrier: args.hostedToolCarrier ?? false,
|
|
43
|
+
};
|
|
44
|
+
state.depth += groupBlockCount(assistant.rawBlocks);
|
|
45
|
+
state.last = assistant;
|
|
46
|
+
return assistant;
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
const addAssistantBlock = (assistant: AssistantGroup): void => {
|
|
50
|
+
const before = groupBlockCount(assistant.rawBlocks);
|
|
51
|
+
assistant.rawBlocks += 1;
|
|
52
|
+
state.depth += groupBlockCount(assistant.rawBlocks) - before;
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
const addOther = (blocks: number): void => {
|
|
56
|
+
state.depth += groupBlockCount(blocks);
|
|
57
|
+
state.last = { kind: "other" };
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
for (const item of request.items) {
|
|
61
|
+
if (item.type === "reasoning") {
|
|
62
|
+
if (
|
|
63
|
+
state.last?.kind === "assistant"
|
|
64
|
+
&& !state.last.hasContent
|
|
65
|
+
&& !state.last.hostedToolCarrier
|
|
66
|
+
) {
|
|
67
|
+
state.last.hasReasoning = true;
|
|
68
|
+
} else {
|
|
69
|
+
startAssistant({ rawBlocks: 0, hasReasoning: true });
|
|
70
|
+
}
|
|
71
|
+
continue;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
if (item.type === "reasoning_configuration") {
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
if (item.type === "tool_result") {
|
|
79
|
+
addOther(1);
|
|
80
|
+
continue;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
if (item.type === "tool_call" || item.type === "hosted_tool_call") {
|
|
84
|
+
const assistant = state.last?.kind === "assistant"
|
|
85
|
+
? state.last
|
|
86
|
+
: startAssistant({ rawBlocks: 0 });
|
|
87
|
+
addAssistantBlock(assistant);
|
|
88
|
+
assistant.hasToolCalls = true;
|
|
89
|
+
if (item.type === "hosted_tool_call") {
|
|
90
|
+
assistant.hostedToolCarrier = true;
|
|
91
|
+
}
|
|
92
|
+
continue;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
if (item.role === "system" || item.role === "developer") {
|
|
96
|
+
state.last = { kind: "other" };
|
|
97
|
+
continue;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const blocks = messageContentBlocks(item);
|
|
101
|
+
const hasContent = blocks > 0;
|
|
102
|
+
if (
|
|
103
|
+
item.role === "assistant"
|
|
104
|
+
&& state.last?.kind === "assistant"
|
|
105
|
+
&& state.last.hasReasoning
|
|
106
|
+
&& !state.last.hasContent
|
|
107
|
+
&& !state.last.hasToolCalls
|
|
108
|
+
) {
|
|
109
|
+
const before = groupBlockCount(state.last.rawBlocks);
|
|
110
|
+
state.last.rawBlocks = blocks;
|
|
111
|
+
state.last.hasContent = hasContent;
|
|
112
|
+
state.depth += groupBlockCount(state.last.rawBlocks) - before;
|
|
113
|
+
continue;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
if (item.role === "assistant") {
|
|
117
|
+
startAssistant({ rawBlocks: blocks, hasContent });
|
|
118
|
+
} else {
|
|
119
|
+
addOther(blocks);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
return state.depth;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function messageContentBlocks(message: InteractionMessage): number {
|
|
127
|
+
if (message.contentShape === "absent" || message.contentShape === "null") {
|
|
128
|
+
return 0;
|
|
129
|
+
}
|
|
130
|
+
if (message.contentShape === "string") {
|
|
131
|
+
return stringContent(message.content).length > 0 ? 1 : 0;
|
|
132
|
+
}
|
|
133
|
+
if (Array.isArray(message.content)) return message.content.length;
|
|
134
|
+
return message.content === null ? 0 : 1;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function stringContent(content: string | InteractionContent[] | null): string {
|
|
138
|
+
if (typeof content === "string") return content;
|
|
139
|
+
if (!Array.isArray(content)) return "";
|
|
140
|
+
return content
|
|
141
|
+
.filter((part): part is { type: "text"; text: string } =>
|
|
142
|
+
part.type === "text"
|
|
143
|
+
)
|
|
144
|
+
.map((part) => part.text)
|
|
145
|
+
.join("");
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function groupBlockCount(rawBlocks: number): number {
|
|
149
|
+
return Math.max(1, rawBlocks);
|
|
150
|
+
}
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
import {
|
|
2
|
+
isReasoningEffort,
|
|
3
|
+
isRecord,
|
|
4
|
+
routingCandidateKey,
|
|
5
|
+
type ChatMessage,
|
|
6
|
+
type JsonValue,
|
|
7
|
+
type RouterPrefixHit,
|
|
8
|
+
type RoutingCandidate,
|
|
9
|
+
type SelectorLeaseHistoryEntry,
|
|
10
|
+
} from "@mupt-ai/dari-router/policy-engine";
|
|
11
|
+
|
|
12
|
+
const MAX_HISTORY_LEASES = 3;
|
|
13
|
+
const MAX_OBSERVATION_CHARS = 3_000;
|
|
14
|
+
const TEST_COMMAND = /(?:^|\s)(?:bun|npm|pnpm|yarn|uv|pytest|cargo|go|make|npx|bash)\b[^\n]*(?:test|check|lint|typecheck|vet|build)/i;
|
|
15
|
+
|
|
16
|
+
type LeaseSegment = {
|
|
17
|
+
candidate: RoutingCandidate;
|
|
18
|
+
requestedTurns: number;
|
|
19
|
+
lastRemaining: number;
|
|
20
|
+
rationale: string;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
type ConversationTurn = {
|
|
24
|
+
assistant: ChatMessage;
|
|
25
|
+
toolResults: ChatMessage[];
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
export function leaseHistoryFromPrefixHits(args: {
|
|
29
|
+
prefixHits: readonly RouterPrefixHit[];
|
|
30
|
+
conversationId: string;
|
|
31
|
+
candidates: readonly RoutingCandidate[];
|
|
32
|
+
messages: readonly ChatMessage[];
|
|
33
|
+
}): SelectorLeaseHistoryEntry[] {
|
|
34
|
+
const candidateKeys = new Set(args.candidates.map(routingCandidateKey));
|
|
35
|
+
const hits = deduplicatedHits(args.prefixHits)
|
|
36
|
+
.filter((hit) => hit.conversation_id === args.conversationId)
|
|
37
|
+
.sort((left, right) => left.message_depth - right.message_depth);
|
|
38
|
+
const segments: LeaseSegment[] = [];
|
|
39
|
+
for (const hit of hits) {
|
|
40
|
+
const remaining = hit.lease_turns_remaining;
|
|
41
|
+
const effort = hit.next_reasoning_effort;
|
|
42
|
+
if (
|
|
43
|
+
typeof remaining !== "number" ||
|
|
44
|
+
!Number.isInteger(remaining) ||
|
|
45
|
+
remaining < 0 ||
|
|
46
|
+
!hit.next_model ||
|
|
47
|
+
!isReasoningEffort(effort)
|
|
48
|
+
) {
|
|
49
|
+
continue;
|
|
50
|
+
}
|
|
51
|
+
const candidate = { model: hit.next_model, reasoningEffort: effort };
|
|
52
|
+
if (!candidateKeys.has(routingCandidateKey(candidate))) continue;
|
|
53
|
+
const current = segments.at(-1);
|
|
54
|
+
if (
|
|
55
|
+
current !== undefined &&
|
|
56
|
+
routingCandidateKey(current.candidate) === routingCandidateKey(candidate) &&
|
|
57
|
+
remaining < current.lastRemaining
|
|
58
|
+
) {
|
|
59
|
+
current.lastRemaining = remaining;
|
|
60
|
+
continue;
|
|
61
|
+
}
|
|
62
|
+
segments.push({
|
|
63
|
+
candidate,
|
|
64
|
+
requestedTurns: remaining + 1,
|
|
65
|
+
lastRemaining: remaining,
|
|
66
|
+
rationale: hit.reason ?? "",
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const recent = segments.slice(-MAX_HISTORY_LEASES);
|
|
71
|
+
const turns = conversationTurns(args.messages);
|
|
72
|
+
let turnCursor = turns.length;
|
|
73
|
+
const history = [...recent].reverse().map((segment) => {
|
|
74
|
+
const completedTurns = Math.max(
|
|
75
|
+
0,
|
|
76
|
+
Math.min(segment.requestedTurns, segment.requestedTurns - segment.lastRemaining),
|
|
77
|
+
);
|
|
78
|
+
const start = Math.max(0, turnCursor - completedTurns);
|
|
79
|
+
const observation = turns.slice(start, turnCursor);
|
|
80
|
+
turnCursor = start;
|
|
81
|
+
return observationForSegment(segment, completedTurns, observation);
|
|
82
|
+
});
|
|
83
|
+
return history.reverse();
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function observationForSegment(
|
|
87
|
+
segment: LeaseSegment,
|
|
88
|
+
completedTurns: number,
|
|
89
|
+
turns: readonly ConversationTurn[],
|
|
90
|
+
): SelectorLeaseHistoryEntry {
|
|
91
|
+
const calls = turns.flatMap((turn) => turn.assistant.tool_calls ?? []);
|
|
92
|
+
const results = turns.flatMap((turn) => turn.toolResults);
|
|
93
|
+
const lastCall = calls.at(-1);
|
|
94
|
+
const callId = typeof lastCall?.["id"] === "string" ? lastCall["id"] : null;
|
|
95
|
+
const lastResult = callId === null
|
|
96
|
+
? undefined
|
|
97
|
+
: [...results].reverse().find((result) => result.tool_call_id === callId);
|
|
98
|
+
const testCall = [...calls].reverse().find((call) => {
|
|
99
|
+
const parsed = toolCall(call);
|
|
100
|
+
return parsed?.name === "bash" && TEST_COMMAND.test(command(parsed.arguments));
|
|
101
|
+
});
|
|
102
|
+
const parsedLastCall = lastCall === undefined ? null : toolCall(lastCall);
|
|
103
|
+
const parsedTestCall = testCall === undefined ? null : toolCall(testCall);
|
|
104
|
+
const testCallId = typeof testCall?.["id"] === "string" ? testCall["id"] : null;
|
|
105
|
+
const testResult = testCallId === null
|
|
106
|
+
? undefined
|
|
107
|
+
: [...results].reverse().find((result) => result.tool_call_id === testCallId);
|
|
108
|
+
const lastAssistant = turns.at(-1)?.assistant;
|
|
109
|
+
const thought = lastAssistant?.reasoning_content
|
|
110
|
+
?? lastAssistant?.reasoning_text
|
|
111
|
+
?? lastAssistant?.reasoning
|
|
112
|
+
?? null;
|
|
113
|
+
return {
|
|
114
|
+
candidate: {
|
|
115
|
+
model: segment.candidate.model,
|
|
116
|
+
thinking_level: segment.candidate.reasoningEffort,
|
|
117
|
+
},
|
|
118
|
+
requested_turns: segment.requestedTurns,
|
|
119
|
+
completed_turns: completedTurns,
|
|
120
|
+
rationale: truncate(segment.rationale),
|
|
121
|
+
last_agent_thought: thought === null ? null : truncate(thought),
|
|
122
|
+
last_tool_call: parsedLastCall === null
|
|
123
|
+
? null
|
|
124
|
+
: { name: parsedLastCall.name, arguments: compactJson(parsedLastCall.arguments) },
|
|
125
|
+
last_tool_result: lastResult === undefined ? null : compactJson(lastResult.content ?? null),
|
|
126
|
+
// Generic Chat Completions tool results do not carry a portable error bit
|
|
127
|
+
// or a trustworthy changed-file count.
|
|
128
|
+
tool_errors: null,
|
|
129
|
+
files_changed: null,
|
|
130
|
+
tests: parsedTestCall === null
|
|
131
|
+
? null
|
|
132
|
+
: {
|
|
133
|
+
command: truncate(command(parsedTestCall.arguments)),
|
|
134
|
+
status: "unknown",
|
|
135
|
+
result: testResult === undefined ? null : compactJson(testResult.content ?? null),
|
|
136
|
+
},
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function deduplicatedHits(hits: readonly RouterPrefixHit[]): RouterPrefixHit[] {
|
|
141
|
+
const byState = new Map<string, RouterPrefixHit>();
|
|
142
|
+
for (const hit of hits) {
|
|
143
|
+
const key = [
|
|
144
|
+
hit.conversation_id,
|
|
145
|
+
hit.message_depth,
|
|
146
|
+
hit.next_model ?? "",
|
|
147
|
+
hit.next_reasoning_effort ?? "",
|
|
148
|
+
hit.lease_turns_remaining ?? "",
|
|
149
|
+
].join("\u0000");
|
|
150
|
+
const existing = byState.get(key);
|
|
151
|
+
if (existing === undefined || existing.updated_at < hit.updated_at) byState.set(key, hit);
|
|
152
|
+
}
|
|
153
|
+
return [...byState.values()];
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function conversationTurns(messages: readonly ChatMessage[]): ConversationTurn[] {
|
|
157
|
+
const turns: ConversationTurn[] = [];
|
|
158
|
+
let current: ConversationTurn | undefined;
|
|
159
|
+
for (const message of messages) {
|
|
160
|
+
if (message.role === "assistant") {
|
|
161
|
+
current = { assistant: message, toolResults: [] };
|
|
162
|
+
turns.push(current);
|
|
163
|
+
} else if (message.role === "tool" && current !== undefined) {
|
|
164
|
+
current.toolResults.push(message);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
return turns;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function toolCall(value: Record<string, unknown>): { name: string; arguments: JsonValue } | null {
|
|
171
|
+
const fn = value["function"];
|
|
172
|
+
if (!isRecord(fn) || typeof fn.name !== "string") return null;
|
|
173
|
+
const rawArguments = fn.arguments ?? {};
|
|
174
|
+
if (typeof rawArguments !== "string") {
|
|
175
|
+
return { name: fn.name, arguments: compactJson(rawArguments) };
|
|
176
|
+
}
|
|
177
|
+
try {
|
|
178
|
+
return { name: fn.name, arguments: JSON.parse(rawArguments) as JsonValue };
|
|
179
|
+
} catch {
|
|
180
|
+
return { name: fn.name, arguments: rawArguments };
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function command(value: JsonValue): string {
|
|
185
|
+
return isRecord(value) && typeof value.command === "string" ? value.command : "";
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function compactJson(value: unknown): JsonValue {
|
|
189
|
+
const serialized = JSON.stringify(value) ?? "null";
|
|
190
|
+
if (serialized.length <= MAX_OBSERVATION_CHARS) {
|
|
191
|
+
return JSON.parse(serialized) as JsonValue;
|
|
192
|
+
}
|
|
193
|
+
return `${serialized.slice(0, MAX_OBSERVATION_CHARS)}…`;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function truncate(value: string): string {
|
|
197
|
+
return value.length <= MAX_OBSERVATION_CHARS
|
|
198
|
+
? value
|
|
199
|
+
: `${value.slice(0, MAX_OBSERVATION_CHARS)}…`;
|
|
200
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import type { ModelPricing, PricingLookup } from "@mupt-ai/dari-router/policy-engine";
|
|
2
|
+
import type { ResolvedRouter } from "../types";
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
export function pricingLookupForRouter(credentials: ResolvedRouter): PricingLookup {
|
|
6
|
+
return (model: string): ModelPricing | null => modelPricingForRouter(credentials, model);
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function modelPricingForRouter(
|
|
10
|
+
credentials: ResolvedRouter,
|
|
11
|
+
model: string,
|
|
12
|
+
): ModelPricing | null {
|
|
13
|
+
const price = credentials.model_prices?.[model];
|
|
14
|
+
if (!price || typeof price.input !== "number" || typeof price.output !== "number") return null;
|
|
15
|
+
// Null cached rates mean the provider publishes none: bill cached prompt
|
|
16
|
+
// buckets at the input rate, never for free.
|
|
17
|
+
return {
|
|
18
|
+
input: price.input,
|
|
19
|
+
output: price.output,
|
|
20
|
+
cacheRead: price.cached_input ?? price.input,
|
|
21
|
+
cacheWrite: price.cache_write ?? price.input,
|
|
22
|
+
...(price.tiers?.length ? { tiers: price.tiers.map((tier) => ({
|
|
23
|
+
inputTokensAbove: tier.input_tokens_above,
|
|
24
|
+
input: tier.input,
|
|
25
|
+
output: tier.output,
|
|
26
|
+
cacheRead: tier.cached_input ?? tier.input,
|
|
27
|
+
cacheWrite: tier.cache_write ?? tier.input,
|
|
28
|
+
})) } : {}),
|
|
29
|
+
};
|
|
30
|
+
}
|