@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.
Files changed (133) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +135 -2
  3. package/dari-gateway.example.yaml +28 -0
  4. package/package.json +42 -4
  5. package/src/bun.ts +4 -0
  6. package/src/context/eval_catalog.ts +11 -0
  7. package/src/context/interaction_fingerprint.ts +691 -0
  8. package/src/context/interaction_fingerprint_format.ts +189 -0
  9. package/src/context/interaction_lease_depth.ts +150 -0
  10. package/src/context/lease_history.ts +200 -0
  11. package/src/context/pricing.ts +30 -0
  12. package/src/context/provider_prompt_accounting.ts +241 -0
  13. package/src/context/routing_context.ts +337 -0
  14. package/src/http/auth.ts +43 -0
  15. package/src/http/errors.ts +114 -0
  16. package/src/http/handler.ts +110 -0
  17. package/src/http/http2_server.ts +122 -0
  18. package/src/http/serve.ts +119 -0
  19. package/src/index.ts +22 -0
  20. package/src/ingress/anthropic_messages.ts +162 -0
  21. package/src/ingress/anthropic_models.ts +28 -0
  22. package/src/ingress/chat_completions.ts +49 -0
  23. package/src/ingress/claude_code/request_context.ts +36 -0
  24. package/src/ingress/codex.ts +81 -0
  25. package/src/ingress/codex_models.ts +113 -0
  26. package/src/ingress/json.ts +113 -0
  27. package/src/ingress/models.ts +55 -0
  28. package/src/interaction/capabilities.ts +30 -0
  29. package/src/interaction/image.ts +12 -0
  30. package/src/interaction/portable_reasoning_state.ts +25 -0
  31. package/src/interaction/provider_continuation_state.ts +36 -0
  32. package/src/interaction/tool_identity.ts +65 -0
  33. package/src/interaction/types.ts +282 -0
  34. package/src/models/context_limits.ts +8 -0
  35. package/src/models/execution_provider.ts +22 -0
  36. package/src/models/gateways.ts +73 -0
  37. package/src/models/ids.ts +26 -0
  38. package/src/models/provider_defaults.ts +47 -0
  39. package/src/models/runtime_overrides.ts +221 -0
  40. package/src/observability/errors.ts +25 -0
  41. package/src/platform/platform.ts +204 -0
  42. package/src/platform/routing_contract.ts +6 -0
  43. package/src/protocol/input/anthropic_messages.ts +531 -0
  44. package/src/protocol/input/chat_completions.ts +255 -0
  45. package/src/protocol/input/chat_completions_validation.ts +293 -0
  46. package/src/protocol/input/openai_responses.ts +749 -0
  47. package/src/protocol/input/router_model.ts +53 -0
  48. package/src/protocol/output/anthropic_messages.ts +32 -0
  49. package/src/protocol/output/anthropic_messages_output.ts +129 -0
  50. package/src/protocol/output/anthropic_messages_stream.ts +281 -0
  51. package/src/protocol/output/chat_completions.ts +451 -0
  52. package/src/protocol/output/chat_reasoning_detail.ts +19 -0
  53. package/src/protocol/output/delivered_stream_snapshot.ts +31 -0
  54. package/src/protocol/output/openai_responses.ts +30 -0
  55. package/src/protocol/output/openai_responses_output.ts +159 -0
  56. package/src/protocol/output/openai_responses_stream.ts +631 -0
  57. package/src/protocol/output/sse_stream.ts +102 -0
  58. package/src/providers/anthropic_safeguards.ts +115 -0
  59. package/src/providers/aws_assume_role.ts +147 -0
  60. package/src/providers/cancel_estimate.ts +140 -0
  61. package/src/providers/execution.ts +800 -0
  62. package/src/providers/model_capabilities.ts +50 -0
  63. package/src/providers/pi.ts +499 -0
  64. package/src/providers/pi_context.ts +466 -0
  65. package/src/providers/pi_options.ts +416 -0
  66. package/src/providers/pi_output.ts +500 -0
  67. package/src/providers/provider_attempt_runner.ts +153 -0
  68. package/src/providers/provider_call.ts +433 -0
  69. package/src/providers/provider_reported_cost.ts +138 -0
  70. package/src/providers/reasoning.ts +59 -0
  71. package/src/providers/response_capture.ts +65 -0
  72. package/src/providers/stream_finalization.ts +264 -0
  73. package/src/providers/subscription_limits.ts +208 -0
  74. package/src/providers/subscription_usage.ts +99 -0
  75. package/src/runtime/claude_code/auxiliary_routing.ts +80 -0
  76. package/src/runtime/customer_disconnect.ts +66 -0
  77. package/src/runtime/explicit_model.ts +86 -0
  78. package/src/runtime/harness_identity/conversation.ts +87 -0
  79. package/src/runtime/harness_identity/extract.ts +323 -0
  80. package/src/runtime/harness_identity/index.ts +14 -0
  81. package/src/runtime/prepare_routed_completion.ts +224 -0
  82. package/src/runtime/provider_stream_lifecycle.ts +105 -0
  83. package/src/runtime/provider_stream_outcome.ts +83 -0
  84. package/src/runtime/request_tags.ts +34 -0
  85. package/src/runtime/routed_completion.ts +102 -0
  86. package/src/runtime/routed_protocol.ts +33 -0
  87. package/src/runtime/routed_runtime.ts +722 -0
  88. package/src/runtime/routing_lease.ts +273 -0
  89. package/src/runtime/runtime_reporter.ts +115 -0
  90. package/src/self_host/config.ts +173 -0
  91. package/src/self_host/main.ts +104 -0
  92. package/src/self_host/platform.ts +63 -0
  93. package/src/self_host/prefix_store.ts +315 -0
  94. package/src/self_host/resolve.ts +240 -0
  95. package/src/self_host/subscriptions/access.ts +43 -0
  96. package/src/self_host/subscriptions/http.ts +204 -0
  97. package/src/self_host/subscriptions/index.ts +43 -0
  98. package/src/self_host/subscriptions/oauth.ts +395 -0
  99. package/src/self_host/subscriptions/store.ts +339 -0
  100. package/src/strategies/auto_routing_selector.ts +72 -0
  101. package/src/strategies/eval_relevance.ts +57 -0
  102. package/src/strategies/fallback_decision.ts +30 -0
  103. package/src/strategies/priority_selector.ts +40 -0
  104. package/src/strategies/route_decision.ts +106 -0
  105. package/src/strategies/routing.ts +455 -0
  106. package/src/strategies/selector_projection.ts +207 -0
  107. package/src/telemetry/background.ts +40 -0
  108. package/src/telemetry/benchmark_replay.ts +160 -0
  109. package/src/telemetry/billing_finalization.ts +167 -0
  110. package/src/telemetry/ingest_values.ts +15 -0
  111. package/src/telemetry/model_cost.ts +14 -0
  112. package/src/telemetry/prefix_state.ts +183 -0
  113. package/src/telemetry/report.ts +832 -0
  114. package/src/telemetry/requests/interaction_observability.ts +258 -0
  115. package/src/telemetry/requests/retained_interaction_request.ts +20 -0
  116. package/src/telemetry/scrub.ts +38 -0
  117. package/src/telemetry/skills/claude/catalog.ts +119 -0
  118. package/src/telemetry/skills/claude/file_reads.ts +153 -0
  119. package/src/telemetry/skills/claude/observations.ts +450 -0
  120. package/src/telemetry/skills/codex/catalog.ts +171 -0
  121. package/src/telemetry/skills/codex/exec_file_reads.ts +55 -0
  122. package/src/telemetry/skills/codex/explicit.ts +116 -0
  123. package/src/telemetry/skills/codex/file_reads.ts +51 -0
  124. package/src/telemetry/skills/codex/identity.ts +21 -0
  125. package/src/telemetry/skills/observations.ts +94 -0
  126. package/src/telemetry/skills/paths.ts +82 -0
  127. package/src/telemetry/skills/pi.ts +276 -0
  128. package/src/telemetry/skills/shell_file_reads.ts +267 -0
  129. package/src/telemetry/skills/shell_words.ts +202 -0
  130. package/src/telemetry/tools/input.ts +16 -0
  131. package/src/telemetry/tools/observations.ts +349 -0
  132. package/src/telemetry/usage.ts +173 -0
  133. 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
+ }