@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,691 @@
1
+ import { createHash } from "node:crypto";
2
+
3
+ import { stableStringify } from "@mupt-ai/dari-router/policy-engine";
4
+
5
+ import { anthropicMessagesToInteraction } from "../protocol/input/anthropic_messages";
6
+ import { internalChatCompletionsToInteraction } from "../protocol/input/chat_completions";
7
+ import { anthropicContent } from "../protocol/output/anthropic_messages_output";
8
+ import { assistantMessagePayload } from "../protocol/output/chat_completions";
9
+ import { compatibleProviderContinuation } from "../interaction/provider_continuation_state";
10
+ import type {
11
+ InteractionItem,
12
+ InteractionOutput,
13
+ InteractionRequest,
14
+ ProviderContinuationState,
15
+ ProviderIdentity,
16
+ } from "../interaction/types";
17
+ import { providerToolCallId, toolWireName } from "../interaction/tool_identity";
18
+ import { ROUTING_PREFIX_HASHES_MAX_COUNT } from "../platform/routing_contract";
19
+ import {
20
+ appendFingerprintContentText,
21
+ canonicalInteractionFingerprintStep,
22
+ interactionFingerprintContent,
23
+ interactionFingerprintHeadHash,
24
+ interactionOptionsFingerprints,
25
+ normalizedAssistantFingerprintContent,
26
+ type InteractionFingerprintStep,
27
+ type ReasoningFingerprint,
28
+ } from "./interaction_fingerprint_format";
29
+
30
+ const MAX_CONTINUATION_TARGETS = 16;
31
+
32
+ type FingerprintPolicy = {
33
+ includeItemIds: boolean;
34
+ includePhase: boolean;
35
+ canonicalizeFunctionArguments: boolean;
36
+ // Loose tier: exclude everything clients may legitimately drop or rewrite
37
+ // (reasoning payloads, item ids, phases) and normalize the rest, so the
38
+ // hash survives echo mutation. Identity-only — never used for warmth.
39
+ loose?: boolean;
40
+ };
41
+
42
+ // The loose tier ignores reasoning and item ids, so provider identity and
43
+ // tool-call-id composition cannot influence it: one chain per request.
44
+ const LOOSE_POLICY: FingerprintPolicy = {
45
+ includeItemIds: false,
46
+ includePhase: false,
47
+ canonicalizeFunctionArguments: true,
48
+ loose: true,
49
+ };
50
+
51
+ // Salts the loose chain's seed so the two hash tiers can never collide in
52
+ // the shared prefix-entry lookup space.
53
+ const LOOSE_SEED_SALT = "fp-loose-1";
54
+ const CONVERSATION_PREFIX_VERSION = "conversation-prefix-v1";
55
+
56
+ export type InteractionPrefixContext = {
57
+ chainByModel: ReadonlyMap<string, string[]>;
58
+ itemIndexByHashByModel: ReadonlyMap<string, ReadonlyMap<string, number>>;
59
+ // Client-durable chain for conversation identity, target-independent.
60
+ looseChain: string[];
61
+ fingerprints: { tool_choice_fp: string; response_format_fp: string };
62
+ };
63
+
64
+ /**
65
+ * Hash variants sent during router resolution. The portable chain covers
66
+ * providers that receive readable history only. Exact continuation-source
67
+ * variants cover a return to the originating model. Because provider APIs
68
+ * are unknown before resolution, Responses tool history also contributes a
69
+ * composite call/item-ID variant for OpenAI-compatible Responses targets.
70
+ */
71
+ export function interactionPrefixLookupHashes(routerId: string, request: InteractionRequest): string[] {
72
+ const targets: Array<ProviderIdentity | null> = [null];
73
+ const seen = new Set<string>();
74
+
75
+ for (let index = request.items.length - 1; index >= 0 && seen.size < MAX_CONTINUATION_TARGETS; index -= 1) {
76
+ const item = request.items[index];
77
+ if (item.type !== "reasoning" || !item.continuation) continue;
78
+ const key = identityKey(item.continuation.source);
79
+ if (seen.has(key)) continue;
80
+ seen.add(key);
81
+ targets.push(item.continuation.source);
82
+ }
83
+
84
+ const chains = targets.map((target) => interactionPrefixChain(routerId, request, target));
85
+ if (request.sourceProtocol === "openai_responses") {
86
+ chains.push(interactionPrefixChainFor(routerId, request, null, "openai-responses"));
87
+ }
88
+ chains.push(interactionLooseChain(routerId, request));
89
+ return deepestDistinctHashes(chains, ROUTING_PREFIX_HASHES_MAX_COUNT);
90
+ }
91
+
92
+ // The loose (client-durable) chain: what the conversation looks like once
93
+ // reasoning payloads, item ids, and provider-specific canonicalization are
94
+ // stripped. Distinct seed keeps its hashes disjoint from the strict tier.
95
+ export function interactionLooseChain(routerId: string, request: InteractionRequest): string[] {
96
+ return fingerprintChain(
97
+ sha256(`${interactionFingerprintHeadHash(routerId, request)}\u0000${LOOSE_SEED_SALT}`),
98
+ looseFingerprintSteps(request.sourceProtocol, request.items),
99
+ );
100
+ }
101
+
102
+ // Stable logical-conversation discriminator. This deliberately excludes the
103
+ // cache head (instructions and tools) and hashes only the first normalized
104
+ // transcript step. Growing history and tool changes preserve identity, while
105
+ // unrelated work reusing the same harness session gets a new group.
106
+ export function interactionConversationPrefix(request: InteractionRequest): string {
107
+ const firstStep = conversationFingerprintSteps(
108
+ looseFingerprintSteps(request.sourceProtocol, request.items),
109
+ ).at(0);
110
+ return sha256(stableStringify({
111
+ version: CONVERSATION_PREFIX_VERSION,
112
+ firstStep: firstStep === undefined
113
+ ? null
114
+ : canonicalInteractionFingerprintStep(firstStep),
115
+ }));
116
+ }
117
+
118
+ export function interactionPrefixContext(
119
+ routerId: string,
120
+ request: InteractionRequest,
121
+ targets: ReadonlyMap<string, ProviderIdentity>
122
+ ): InteractionPrefixContext {
123
+ const chainByModel = new Map<string, string[]>();
124
+ const itemIndexByHashByModel = new Map<string, ReadonlyMap<string, number>>();
125
+ for (const [model, target] of targets) {
126
+ const steps = interactionPrefixStepsFor(routerId, request, target, target.api);
127
+ chainByModel.set(model, steps.map((step) => step.hash));
128
+ itemIndexByHashByModel.set(
129
+ model,
130
+ new Map(steps.map((step) => [step.hash, step.itemIndex])),
131
+ );
132
+ }
133
+ return {
134
+ chainByModel,
135
+ itemIndexByHashByModel,
136
+ looseChain: interactionLooseChain(routerId, request),
137
+ fingerprints: interactionOptionsFingerprints(request),
138
+ };
139
+ }
140
+
141
+ // Loose fingerprints share the strict grouping logic so step boundaries
142
+ // stay identical whether or not the client echoed reasoning; the policy just
143
+ // drops the volatile fields. Target-independent by construction.
144
+ function looseFingerprintSteps(
145
+ sourceProtocol: InteractionRequest["sourceProtocol"],
146
+ items: ReadonlyArray<InteractionItem>,
147
+ ): InteractionFingerprintStep[] {
148
+ return sourceProtocol === "openai_chat"
149
+ ? fingerprintChatInputSteps(items, null, null, LOOSE_POLICY)
150
+ : fingerprintSteps(items, null, null, LOOSE_POLICY);
151
+ }
152
+
153
+ export function interactionPrefixChain(
154
+ routerId: string,
155
+ request: InteractionRequest,
156
+ target: ProviderIdentity | null
157
+ ): string[] {
158
+ return interactionPrefixStepsFor(routerId, request, target, target?.api ?? null)
159
+ .map((step) => step.hash);
160
+ }
161
+
162
+ function interactionPrefixChainFor(
163
+ routerId: string,
164
+ request: InteractionRequest,
165
+ target: ProviderIdentity | null,
166
+ toolCallApi: string | null
167
+ ): string[] {
168
+ return interactionPrefixStepsFor(routerId, request, target, toolCallApi)
169
+ .map((step) => step.hash);
170
+ }
171
+
172
+ function interactionPrefixStepsFor(
173
+ routerId: string,
174
+ request: InteractionRequest,
175
+ target: ProviderIdentity | null,
176
+ toolCallApi: string | null,
177
+ ): Array<{ hash: string; itemIndex: number }> {
178
+ let hash = interactionFingerprintHeadHash(routerId, request);
179
+ const steps: Array<{ hash: string; itemIndex: number }> = [];
180
+ const responses = request.sourceProtocol === "openai_responses";
181
+ const fingerprintStepsForRequest = conversationFingerprintSteps(
182
+ request.sourceProtocol === "openai_chat"
183
+ ? fingerprintChatInputSteps(request.items, target, toolCallApi)
184
+ : fingerprintSteps(request.items, target, toolCallApi, {
185
+ includeItemIds: true,
186
+ includePhase: responses,
187
+ canonicalizeFunctionArguments: responses,
188
+ }),
189
+ );
190
+ for (const step of fingerprintStepsForRequest) {
191
+ hash = extendFingerprintHash(hash, step);
192
+ steps.push({ hash, itemIndex: step.itemIndex });
193
+ }
194
+ return steps;
195
+ }
196
+
197
+ export function extendInteractionChain(
198
+ lastHash: string,
199
+ output: InteractionOutput,
200
+ target: ProviderIdentity,
201
+ policy: {
202
+ includeItemIds: boolean;
203
+ sourceProtocol: InteractionRequest["sourceProtocol"];
204
+ }
205
+ ): string[] {
206
+ let hash = lastHash;
207
+ const extension: string[] = [];
208
+ const steps = fingerprintClientEchoSteps(output, target, policy);
209
+ for (const step of steps) {
210
+ if (step.actor === "system" || step.actor === "developer") continue;
211
+ hash = extendFingerprintHash(hash, step);
212
+ extension.push(hash);
213
+ }
214
+ return extension;
215
+ }
216
+
217
+ function fingerprintClientEchoSteps(
218
+ output: InteractionOutput,
219
+ target: ProviderIdentity,
220
+ policy: {
221
+ includeItemIds: boolean;
222
+ sourceProtocol: InteractionRequest["sourceProtocol"];
223
+ },
224
+ ): InteractionFingerprintStep[] {
225
+ const items = clientEchoItems(output, policy.sourceProtocol);
226
+ if (policy.sourceProtocol === "openai_chat") {
227
+ return fingerprintChatInputSteps(items, target, target.api);
228
+ }
229
+ return fingerprintSteps(items, target, target.api, {
230
+ includeItemIds: policy.includeItemIds,
231
+ includePhase: policy.sourceProtocol === "openai_responses",
232
+ canonicalizeFunctionArguments: true,
233
+ });
234
+ }
235
+
236
+ // Prefix extensions must match the assistant output as the next client request
237
+ // will carry it, not necessarily the provider's canonical output items.
238
+ function clientEchoItems(
239
+ output: InteractionOutput,
240
+ sourceProtocol: InteractionRequest["sourceProtocol"],
241
+ ): InteractionItem[] {
242
+ if (sourceProtocol === "openai_chat") {
243
+ return internalChatCompletionsToInteraction({
244
+ model: "dari/routing",
245
+ messages: [assistantMessagePayload(output)],
246
+ }).items;
247
+ }
248
+ if (sourceProtocol === "anthropic_messages") {
249
+ return anthropicMessagesToInteraction({
250
+ model: "dari/routing",
251
+ max_tokens: 1,
252
+ messages: [{ role: "assistant", content: anthropicContent(output) }],
253
+ }).items;
254
+ }
255
+ return output.items;
256
+ }
257
+
258
+ // Loose-tier extension for the reply, mirroring interactionLooseChain's
259
+ // policy: what the next request's loose chain computes at this depth no
260
+ // matter which volatile fields the client's echo keeps.
261
+ export function extendInteractionLooseChain(
262
+ lastHash: string,
263
+ output: InteractionOutput,
264
+ sourceProtocol: InteractionRequest["sourceProtocol"],
265
+ ): string[] {
266
+ let hash = lastHash;
267
+ const extension: string[] = [];
268
+ const items = clientEchoItems(output, sourceProtocol);
269
+ for (const step of looseFingerprintSteps(sourceProtocol, items)) {
270
+ if (step.actor === "system" || step.actor === "developer") continue;
271
+ hash = extendFingerprintHash(hash, step);
272
+ extension.push(hash);
273
+ }
274
+ return extension;
275
+ }
276
+
277
+ // Chat carries reasoning, assistant text, and tool calls in one assistant
278
+ // message. Its canonical interaction items intentionally separate those
279
+ // concerns, so prefix hashing must regroup one wire message before hashing.
280
+ function fingerprintChatInputSteps(
281
+ items: ReadonlyArray<InteractionItem>,
282
+ target: ProviderIdentity | null,
283
+ toolCallApi: string | null,
284
+ policy?: FingerprintPolicy,
285
+ ): InteractionFingerprintStep[] {
286
+ const steps: InteractionFingerprintStep[] = [];
287
+ const toolCalls = new Map<string, string>();
288
+ let pendingReasoning: ReasoningFingerprint[] = [];
289
+ let pendingReasoningItemIndex = -1;
290
+
291
+ const flushReasoning = () => {
292
+ if (pendingReasoning.length === 0) return;
293
+ steps.push({
294
+ actor: "assistant",
295
+ itemIndex: pendingReasoningItemIndex,
296
+ content: null,
297
+ calls: [],
298
+ resultCallId: null,
299
+ reasoning: pendingReasoning,
300
+ });
301
+ pendingReasoning = [];
302
+ pendingReasoningItemIndex = -1;
303
+ };
304
+
305
+ for (const [itemIndex, item] of items.entries()) {
306
+ if (item.type === "reasoning") {
307
+ if (policy?.loose) continue;
308
+ pendingReasoning.push(reasoningFingerprint(item, target, false));
309
+ pendingReasoningItemIndex = itemIndex;
310
+ continue;
311
+ }
312
+ if (item.type === "message") {
313
+ if (item.role !== "assistant") flushReasoning();
314
+ steps.push({
315
+ actor: item.role,
316
+ itemIndex,
317
+ content: item.role === "assistant"
318
+ ? normalizedAssistantFingerprintContent(item.content, item.contentShape)
319
+ : interactionFingerprintContent(item.content, item.contentShape),
320
+ calls: [],
321
+ resultCallId: null,
322
+ ...(item.role === "assistant" && pendingReasoning.length > 0
323
+ ? { reasoning: pendingReasoning }
324
+ : {}),
325
+ });
326
+ pendingReasoning = [];
327
+ pendingReasoningItemIndex = -1;
328
+ continue;
329
+ }
330
+ if (item.type === "reasoning_configuration") {
331
+ // Configuration updates change execution effort without becoming part
332
+ // of the conversational identity, just like request-level effort.
333
+ flushReasoning();
334
+ continue;
335
+ }
336
+ if (item.type === "tool_result") {
337
+ flushReasoning();
338
+ steps.push({
339
+ actor: "tool",
340
+ itemIndex,
341
+ content: interactionFingerprintContent(item.output, item.outputShape),
342
+ calls: [],
343
+ resultCallId: toolCalls.get(item.callId) ?? item.callId,
344
+ });
345
+ continue;
346
+ }
347
+
348
+ flushReasoning();
349
+ let assistant = steps.at(-1);
350
+ if (!assistant || assistant.actor !== "assistant") {
351
+ assistant = {
352
+ actor: "assistant",
353
+ content: null,
354
+ calls: [],
355
+ resultCallId: null,
356
+ itemIndex,
357
+ };
358
+ steps.push(assistant);
359
+ }
360
+ assistant.itemIndex = itemIndex;
361
+ if (item.type === "tool_call") {
362
+ const id = appendToolCall(
363
+ assistant,
364
+ item,
365
+ toolCallApi,
366
+ false,
367
+ policy?.canonicalizeFunctionArguments ?? false,
368
+ );
369
+ toolCalls.set(item.callId, id);
370
+ } else {
371
+ appendHostedToolCall(assistant, item);
372
+ }
373
+ }
374
+ flushReasoning();
375
+ return steps;
376
+ }
377
+
378
+ function fingerprintSteps(
379
+ items: ReadonlyArray<InteractionItem>,
380
+ target: ProviderIdentity | null,
381
+ toolCallApi: string | null,
382
+ policy: FingerprintPolicy
383
+ ): InteractionFingerprintStep[] {
384
+ const steps: InteractionFingerprintStep[] = [];
385
+ const toolCalls = new Map<string, string>();
386
+
387
+ for (const [itemIndex, item] of items.entries()) {
388
+ if (item.type === "reasoning") {
389
+ if (policy.loose) continue;
390
+ const continuation = compatibleProviderContinuation(item.continuation, target);
391
+ const readable = continuationReadableText(item, continuation);
392
+ const itemFingerprint = reasoningFingerprint(item, target, policy.includeItemIds);
393
+ const previous = steps.at(-1);
394
+ if (
395
+ previous?.actor === "assistant"
396
+ && (previous.calls.length > 0 || previous.reasoning?.length)
397
+ ) {
398
+ previous.itemIndex = itemIndex;
399
+ previous.content = appendFingerprintContentText(previous.content, readable);
400
+ previous.reasoning = [...(previous.reasoning ?? []), itemFingerprint];
401
+ continue;
402
+ }
403
+ steps.push({
404
+ actor: "assistant",
405
+ itemIndex,
406
+ content: readable || null,
407
+ calls: [],
408
+ resultCallId: null,
409
+ reasoning: [itemFingerprint],
410
+ });
411
+ continue;
412
+ }
413
+ if (item.type === "reasoning_configuration") {
414
+ continue;
415
+ }
416
+ if (item.type === "tool_result") {
417
+ steps.push({
418
+ actor: "tool",
419
+ itemIndex,
420
+ content: interactionFingerprintContent(item.output, item.outputShape),
421
+ calls: [],
422
+ resultCallId: toolCalls.get(item.callId) ?? item.callId,
423
+ });
424
+ continue;
425
+ }
426
+ if (item.type === "tool_call") {
427
+ let assistant = steps.at(-1);
428
+ if (!assistant || assistant.actor !== "assistant") {
429
+ assistant = {
430
+ actor: "assistant",
431
+ content: null,
432
+ calls: [],
433
+ resultCallId: null,
434
+ itemIndex,
435
+ };
436
+ steps.push(assistant);
437
+ }
438
+ assistant.itemIndex = itemIndex;
439
+ const id = appendToolCall(
440
+ assistant,
441
+ item,
442
+ toolCallApi,
443
+ policy.includeItemIds,
444
+ policy.canonicalizeFunctionArguments,
445
+ );
446
+ toolCalls.set(item.callId, id);
447
+ continue;
448
+ }
449
+ if (item.type === "hosted_tool_call") {
450
+ let assistant = steps.at(-1);
451
+ if (!assistant || assistant.actor !== "assistant") {
452
+ assistant = {
453
+ actor: "assistant",
454
+ content: null,
455
+ calls: [],
456
+ resultCallId: null,
457
+ itemIndex,
458
+ };
459
+ steps.push(assistant);
460
+ }
461
+ assistant.itemIndex = itemIndex;
462
+ appendHostedToolCall(assistant, item);
463
+ continue;
464
+ }
465
+ steps.push({
466
+ actor: item.role,
467
+ itemIndex,
468
+ content: policy.loose && item.role === "assistant"
469
+ ? normalizedAssistantFingerprintContent(item.content, item.contentShape)
470
+ : interactionFingerprintContent(item.content, item.contentShape),
471
+ calls: [],
472
+ resultCallId: null,
473
+ ...(policy.includeItemIds && item.id ? { itemId: item.id } : {}),
474
+ ...(policy.includePhase && item.phase ? { phase: item.phase } : {}),
475
+ });
476
+ }
477
+ return steps;
478
+ }
479
+
480
+ function reasoningFingerprint(
481
+ item: Extract<InteractionItem, { type: "reasoning" }>,
482
+ target: ProviderIdentity | null,
483
+ includeItemId: boolean,
484
+ ): ReasoningFingerprint {
485
+ const continuation = compatibleProviderContinuation(item.continuation, target);
486
+ const readable = continuationReadableText(item, continuation);
487
+ return {
488
+ ...(includeItemId && item.id ? { itemId: item.id } : {}),
489
+ readableSha256: sha256(readable),
490
+ ...(continuation
491
+ ? { continuationStateSha256: sha256(stableStringify(continuation)) }
492
+ : {}),
493
+ };
494
+ }
495
+
496
+ function appendToolCall(
497
+ assistant: InteractionFingerprintStep,
498
+ item: Extract<InteractionItem, { type: "tool_call" }>,
499
+ toolCallApi: string | null,
500
+ includeItemId: boolean,
501
+ canonicalizeFunctionArguments: boolean,
502
+ ): string {
503
+ const id = providerToolCallId(
504
+ item.callId,
505
+ includeItemId ? item.id : undefined,
506
+ toolCallApi,
507
+ );
508
+ assistant.calls = [
509
+ ...assistant.calls,
510
+ {
511
+ id,
512
+ kind: item.kind,
513
+ name: toolWireName(item.namespace, item.name),
514
+ input: toolArguments(
515
+ item.kind,
516
+ item.input,
517
+ canonicalizeFunctionArguments,
518
+ ),
519
+ },
520
+ ];
521
+ return id;
522
+ }
523
+
524
+ function appendHostedToolCall(
525
+ assistant: InteractionFingerprintStep,
526
+ item: Extract<InteractionItem, { type: "hosted_tool_call" }>,
527
+ ): void {
528
+ const payload: Record<string, unknown> = {
529
+ ...item.payload,
530
+ type: item.providerType,
531
+ id: item.id,
532
+ };
533
+ if (item.status) payload.status = item.status;
534
+ else delete payload.status;
535
+ assistant.calls = [
536
+ ...assistant.calls,
537
+ {
538
+ id: item.id,
539
+ kind: "hosted",
540
+ name: item.tool,
541
+ input: payload,
542
+ },
543
+ ];
544
+ }
545
+
546
+ function continuationReadableText(
547
+ item: Extract<InteractionItem, { type: "reasoning" }>,
548
+ continuation: ProviderContinuationState | null
549
+ ): string {
550
+ if (continuation?.kind === "anthropic_thinking") return normalizedReadable(continuation.thinking);
551
+ if (continuation?.kind === "anthropic_redacted_thinking") return "";
552
+ return normalizedReadable([...item.summary, ...item.content].join("\n"));
553
+ }
554
+
555
+ // Streamed reasoning reaches clients as delta concatenations whose whitespace
556
+ // joins differ from the aggregated response body (part separators, trailing
557
+ // newlines), and the client echoes whichever form it saw. Whitespace therefore
558
+ // cannot participate in conversation identity.
559
+ function normalizedReadable(text: string): string {
560
+ return text.replace(/\s+/g, " ").trim();
561
+ }
562
+
563
+ function fingerprintChain(
564
+ seed: string,
565
+ steps: ReadonlyArray<InteractionFingerprintStep>,
566
+ ): string[] {
567
+ let hash = seed;
568
+ return conversationFingerprintSteps(steps).map((step) => {
569
+ hash = extendFingerprintHash(hash, step);
570
+ return hash;
571
+ });
572
+ }
573
+
574
+ function conversationFingerprintSteps(
575
+ steps: ReadonlyArray<InteractionFingerprintStep>,
576
+ ): InteractionFingerprintStep[] {
577
+ const positional: InteractionFingerprintStep[] = [];
578
+ let sawConversationStep = false;
579
+ for (const step of steps) {
580
+ if (step.actor !== "system" && step.actor !== "developer") {
581
+ sawConversationStep = true;
582
+ const previous = positional.at(-1);
583
+ if (step.actor === "user" && previous?.actor === "user") {
584
+ previous.content = appendPositionalFingerprintContent(previous.content, step.content);
585
+ previous.itemIndex = step.itemIndex;
586
+ } else {
587
+ positional.push({ ...step });
588
+ }
589
+ continue;
590
+ }
591
+ if (!sawConversationStep) continue;
592
+
593
+ const text = fingerprintReadableText(step.content);
594
+ if (!text) continue;
595
+ const previous = positional.at(-1);
596
+ if (previous?.actor === "user" || previous?.actor === "tool") {
597
+ previous.content = appendPositionalFingerprintText(previous.content, text);
598
+ previous.itemIndex = step.itemIndex;
599
+ continue;
600
+ }
601
+ positional.push({
602
+ actor: "user",
603
+ content: [{ type: "text", text }],
604
+ calls: [],
605
+ resultCallId: null,
606
+ itemIndex: step.itemIndex,
607
+ });
608
+ }
609
+ return positional;
610
+ }
611
+
612
+ function fingerprintReadableText(content: InteractionFingerprintStep["content"]): string {
613
+ if (typeof content === "string") return content;
614
+ if (!Array.isArray(content)) return "";
615
+ return content.map((part) => part.type === "text" ? part.text : "(image omitted)").join("\n");
616
+ }
617
+
618
+ function appendPositionalFingerprintText(
619
+ content: InteractionFingerprintStep["content"],
620
+ text: string,
621
+ ): InteractionFingerprintStep["content"] {
622
+ return appendPositionalFingerprintContent(content, [{ type: "text", text }]);
623
+ }
624
+
625
+ function appendPositionalFingerprintContent(
626
+ content: InteractionFingerprintStep["content"],
627
+ appended: InteractionFingerprintStep["content"],
628
+ ): InteractionFingerprintStep["content"] {
629
+ const contentParts = typeof content === "string"
630
+ ? [{ type: "text" as const, text: content }]
631
+ : content ?? [];
632
+ const appendedParts = typeof appended === "string"
633
+ ? [{ type: "text" as const, text: appended }]
634
+ : appended ?? [];
635
+ return [...contentParts, ...appendedParts];
636
+ }
637
+
638
+ function extendFingerprintHash(
639
+ lastHash: string,
640
+ step: InteractionFingerprintStep,
641
+ ): string {
642
+ return sha256(
643
+ `${lastHash}\u0000${canonicalInteractionFingerprintStep(step)}`,
644
+ );
645
+ }
646
+
647
+ function toolArguments(
648
+ kind: "function" | "custom",
649
+ input: string | Record<string, unknown> | undefined,
650
+ canonicalizeFunctionArguments: boolean
651
+ ): unknown {
652
+ if (kind === "custom") return JSON.stringify({ input: typeof input === "string" ? input : "" });
653
+ if (canonicalizeFunctionArguments && typeof input === "string") {
654
+ try {
655
+ const parsed: unknown = JSON.parse(input);
656
+ if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) return parsed;
657
+ } catch {
658
+ // Provider execution reports malformed function arguments separately;
659
+ // fingerprinting itself must remain best-effort.
660
+ }
661
+ }
662
+ return input;
663
+ }
664
+
665
+ function deepestDistinctHashes(chains: string[][], max: number): string[] {
666
+ const result: string[] = [];
667
+ const seen = new Set<string>();
668
+ for (let depth = 1; result.length < max; depth += 1) {
669
+ let found = false;
670
+ for (const chain of chains) {
671
+ const hash = chain.at(-depth);
672
+ if (!hash) continue;
673
+ found = true;
674
+ if (!seen.has(hash)) {
675
+ seen.add(hash);
676
+ result.push(hash);
677
+ if (result.length === max) break;
678
+ }
679
+ }
680
+ if (!found) break;
681
+ }
682
+ return result;
683
+ }
684
+
685
+ function identityKey(identity: ProviderIdentity): string {
686
+ return `${identity.provider}\u0000${identity.api}\u0000${identity.model}`;
687
+ }
688
+
689
+ function sha256(text: string): string {
690
+ return createHash("sha256").update(text, "utf8").digest("hex");
691
+ }