@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,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
|
+
}
|