blob: 716f9bd0b78d9155b57e99627a29ef451cb2f51f [file]
import { Buffer } from 'node:buffer';
import { createHash } from 'node:crypto';
import type { RuntimeExecutionConnection } from '@maka/core/llm-connections';
import type {
PrefixChangeReason,
ToolSchemaChangeReason,
ToolAvailabilityDiagnostic,
} from '@maka/core/usage-stats/types';
import type { ModelMessage } from './model-protocol.js';
import { toJSONSchema } from 'zod';
import type { MakaTool } from './tool-runtime.js';
export interface CanonicalToolSet {
providerTools: MakaTool[];
activeTools: string[];
}
export interface RequestShapeInput {
connection: RuntimeExecutionConnection;
modelId: string;
systemPrompt?: string;
providerOptions?: Record<string, unknown>;
providerTools: readonly MakaTool[];
activeTools: readonly string[];
priorMessages: readonly ModelMessage[];
toolAvailability?: ToolAvailabilityDiagnostic;
}
export interface RequestShapeComponents {
modelProviderHash: string;
systemPromptHash: string;
providerOptionsHash: string;
toolSchemaHash: string;
historyProjectionHash: string;
}
export type DurablePrefixComponents = Omit<RequestShapeComponents, 'historyProjectionHash'>;
export interface RequestShapeDiagnostic {
/** Durable provider prefix shape, excluding prior-history projection. */
prefixHash: string;
prefixChangeReason: PrefixChangeReason;
/** Full request shape, including prior-history projection. */
requestShapeHash: string;
requestShapeChangeReason: PrefixChangeReason;
componentHashes: RequestShapeComponents;
toolSchemaChangeReason?: ToolSchemaChangeReason;
toolAvailability?: ToolAvailabilityDiagnostic;
}
export type PreparedRequestSegmentKind =
| 'tool_schema'
| 'system_prompt'
| 'message'
| 'provider_options';
export interface PreparedRequestSegment {
kind: PreparedRequestSegmentKind;
index: number;
cacheable: boolean;
hash: string;
bytes: number;
role?: string;
}
export interface PreparedProviderRequestInput {
providerId: string;
modelId: string;
instructions?: unknown;
messages: readonly unknown[];
tools?: readonly unknown[];
providerOptions?: Record<string, unknown>;
/** Exact secret-free model-call parameters captured at the provider seam. */
requestPayload?: unknown;
}
export interface PreparedProviderRequestCapture {
schemaVersion: 2;
requestHash: string;
/** Hash of protocol-independent model-call semantics for cross-protocol comparison. */
requestPayloadWithoutProviderOptionsHash: string;
requestBytes: number;
serializedRequest: string;
segments: PreparedRequestSegment[];
}
export type PreparedRequestSegmentRef = Pick<PreparedRequestSegment, 'kind' | 'index' | 'role'>;
/**
* Split the registry into the full dispatch set (`providerTools`) and the
* model-visible subset (`activeTools`).
*
* `activeNames` is the explicit allow-list of tools to advertise this step —
* the single source of truth computed by `ToolAvailabilityRuntime` (core +
* ungrouped + loaded groups). A tool absent from it is withheld from
* `activeTools` but stays in `providerTools` so it remains dispatchable once
* its group loads. Omitting `activeNames` advertises every visible tool — the
* full-surface case (economy off / no gating).
*/
export function canonicalizeToolSet(
tools: readonly MakaTool[],
invalidTool: MakaTool,
activeNames?: ReadonlySet<string>,
): CanonicalToolSet {
const visibleTools = tools
.filter((tool) => tool.name !== invalidTool.name)
.slice()
.sort((a, b) => a.name.localeCompare(b.name));
// providerTools stays the full registry (dispatch never depends on visibility).
// activeTools is the model-visible subset the AI SDK serializes to the
// provider, so a gated-and-unloaded schema stays off the wire.
const activeTools = visibleTools
.filter((tool) => activeNames === undefined || activeNames.has(tool.name))
.map((tool) => tool.name);
return {
providerTools: [...visibleTools, invalidTool],
activeTools,
};
}
export function computeRequestShapeDiagnostic(
input: RequestShapeInput,
prior: RequestShapeDiagnostic | undefined,
): RequestShapeDiagnostic {
const componentHashes: RequestShapeComponents = {
modelProviderHash: stableHash({
providerId: input.connection.providerType,
connectionSlug: input.connection.slug,
modelId: input.modelId,
}),
systemPromptHash: stableHash(input.systemPrompt ?? ''),
providerOptionsHash: stableHash(input.providerOptions ?? {}),
toolSchemaHash: stableHash({
activeTools: [...input.activeTools],
// Only the provider-visible (active) subset crosses the wire, so the
// schema hash must reflect that subset — otherwise an inactive deferred
// tool's schema change would falsely fire `tool_schema_changed`, and a
// load would not be distinguishable from churn.
providerTools: providerVisibleTools(input.providerTools, input.activeTools).map(
toolShapeForDiagnostics,
),
}),
historyProjectionHash: stableHash(input.priorMessages.map(messageShapeForHash)),
};
const durablePrefixComponents = durableComponents(componentHashes);
const prefixHash = stableHash(durablePrefixComponents);
const requestShapeHash = stableHash(componentHashes);
const toolSchemaChangeReason = classifyToolSchemaChange(
componentHashes,
prior?.componentHashes,
input.toolAvailability,
prior?.toolAvailability,
);
return {
prefixHash,
prefixChangeReason: classifyDurablePrefixChange(
durablePrefixComponents,
prior ? durableComponents(prior.componentHashes) : undefined,
),
requestShapeHash,
requestShapeChangeReason: classifyRequestShapeChange(componentHashes, prior?.componentHashes),
componentHashes,
...(toolSchemaChangeReason !== undefined ? { toolSchemaChangeReason } : {}),
...(input.toolAvailability !== undefined ? { toolAvailability: input.toolAvailability } : {}),
};
}
export function toolSchemaCharsForDiagnostics(
providerTools: readonly MakaTool[],
activeTools: readonly string[],
): number {
return stableStringify({
activeTools: [...activeTools],
providerTools: providerVisibleTools(providerTools, activeTools).map(toolShapeForDiagnostics),
}).length;
}
/**
* Capture the standardized request immediately before the provider call.
*
* Segment order follows the stable Maka request-prefix model used for cache
* diagnostics: tools, system instructions, then conversation messages.
* Provider options are retained for exact replay evidence, but are not claimed
* to be a provider-cacheable prefix segment.
*/
export function capturePreparedProviderRequest(
input: PreparedProviderRequestInput,
): PreparedProviderRequestCapture {
const payload = input.requestPayload ?? {
instructions: input.instructions,
messages: input.messages,
tools: input.tools ?? [],
providerOptions: input.providerOptions ?? {},
};
// This is the evidence body, not the hash canonicalizer: preserve the exact
// JSON ordering and values presented at the model-call seam.
const serializedRequest = JSON.stringify(payload);
const segments: PreparedRequestSegment[] = [];
for (const [index, tool] of (input.tools ?? []).entries()) {
segments.push(preparedSegment('tool_schema', index, tool, true));
}
if (input.instructions !== undefined) {
const instructions = Array.isArray(input.instructions)
? input.instructions
: [input.instructions];
for (const [index, instruction] of instructions.entries()) {
segments.push(preparedSegment('system_prompt', index, instruction, true));
}
}
for (const [index, message] of input.messages.entries()) {
const role =
isObjectLike(message) && typeof message.role === 'string' ? message.role : undefined;
segments.push(preparedSegment('message', index, message, true, role));
}
if (input.providerOptions !== undefined) {
segments.push(preparedSegment('provider_options', 0, input.providerOptions, false));
}
return {
schemaVersion: 2,
requestHash: stableHash({
providerId: input.providerId,
modelId: input.modelId,
payload,
}),
requestPayloadWithoutProviderOptionsHash: stableHash(
protocolIndependentRequestPayload(payload),
),
requestBytes: Buffer.byteLength(serializedRequest, 'utf8'),
serializedRequest,
segments,
};
}
function protocolIndependentRequestPayload(payload: unknown): unknown {
if (!isObjectLike(payload)) return payload;
const { providerOptions, ...shared } = payload;
const identities: ProtocolIndependentRequestIdentities = {
approvalIds: new Map(),
toolCallIds: new Map(),
};
const reasoningEffort = protocolIndependentReasoningEffort(providerOptions);
const protocolIndependent: Record<string, unknown> = {
...shared,
...(Array.isArray(shared.prompt)
? {
prompt: shared.prompt.map((message) => withoutPromptProviderOptions(message, identities)),
}
: {}),
...(Array.isArray(shared.messages)
? {
messages: shared.messages.map((message) =>
withoutPromptProviderOptions(message, identities),
),
}
: {}),
...(Array.isArray(shared.tools)
? { tools: shared.tools.map(withoutObjectProviderOptions) }
: {}),
...(reasoningEffort !== undefined ? { reasoningEffort } : {}),
};
const thinkingBudget = anthropicThinkingBudget(providerOptions);
if (
thinkingBudget === undefined ||
!isNonNegativeSafeInteger(protocolIndependent.maxOutputTokens)
) {
return protocolIndependent;
}
const wireOutputLimit = protocolIndependent.maxOutputTokens + thinkingBudget;
return Number.isSafeInteger(wireOutputLimit)
? { ...protocolIndependent, maxOutputTokens: wireOutputLimit }
: protocolIndependent;
}
function protocolIndependentReasoningEffort(
providerOptions: unknown,
): string | string[] | undefined {
if (!isObjectLike(providerOptions)) return undefined;
const efforts = new Set<string>();
const anthropic = providerOptions.anthropic;
if (isObjectLike(anthropic) && typeof anthropic.effort === 'string') {
efforts.add(anthropic.effort);
}
for (const namespace of Object.values(providerOptions)) {
if (!isObjectLike(namespace)) continue;
if (typeof namespace.reasoningEffort === 'string') efforts.add(namespace.reasoningEffort);
if (isObjectLike(namespace.thinking) && namespace.thinking.type === 'disabled') {
efforts.add('none');
}
const thinkingConfig = namespace.thinkingConfig;
if (isObjectLike(thinkingConfig) && typeof thinkingConfig.thinkingLevel === 'string') {
efforts.add(thinkingConfig.thinkingLevel);
}
if (isObjectLike(thinkingConfig) && thinkingConfig.thinkingBudget === 0) {
efforts.add('none');
}
const chatTemplateKwargs = namespace.chat_template_kwargs;
if (isObjectLike(chatTemplateKwargs) && chatTemplateKwargs.thinking === false) {
efforts.add('none');
}
}
const normalized = [...efforts].sort();
return normalized.length > 1 ? normalized : normalized[0];
}
interface ProtocolIndependentRequestIdentities {
approvalIds: Map<string, string>;
toolCallIds: Map<string, string>;
}
function withoutPromptProviderOptions(
value: unknown,
identities: ProtocolIndependentRequestIdentities,
): unknown {
const message = withoutObjectProviderOptions(value);
if (!isObjectLike(message) || !Array.isArray(message.content)) return message;
return {
...message,
content: message.content.map((part) => withoutPromptPartProviderOptions(part, identities)),
};
}
function withoutPromptPartProviderOptions(
value: unknown,
identities: ProtocolIndependentRequestIdentities,
): unknown {
const part = withoutObjectProviderOptions(value);
if (!isObjectLike(part)) return part;
if (part.type === 'tool-call') {
const { providerExecuted: _providerExecuted, ...shared } = part;
return {
...shared,
toolCallId: protocolIndependentId(part.toolCallId, identities.toolCallIds, 'tool-call'),
};
}
if (part.type === 'tool-approval-request') {
const { isAutomatic: _isAutomatic, signature: _signature, ...shared } = part;
return {
...shared,
approvalId: protocolIndependentId(part.approvalId, identities.approvalIds, 'approval'),
toolCallId: protocolIndependentId(part.toolCallId, identities.toolCallIds, 'tool-call'),
};
}
if (part.type === 'tool-approval-response') {
const { providerExecuted: _providerExecuted, ...shared } = part;
return {
...shared,
approvalId: protocolIndependentId(part.approvalId, identities.approvalIds, 'approval'),
};
}
if (part.type !== 'tool-result') return part;
const output = withoutObjectProviderOptions(part.output);
const normalizedPart = {
...part,
toolCallId: protocolIndependentId(part.toolCallId, identities.toolCallIds, 'tool-call'),
};
if (!isObjectLike(output) || output.type !== 'content' || !Array.isArray(output.value)) {
return { ...normalizedPart, output };
}
return {
...normalizedPart,
output: { ...output, value: output.value.map(withoutObjectProviderOptions) },
};
}
function protocolIndependentId(
value: unknown,
identities: Map<string, string>,
prefix: string,
): unknown {
if (typeof value !== 'string') return value;
const existing = identities.get(value);
if (existing) return existing;
const normalized = `${prefix}-${identities.size + 1}`;
identities.set(value, normalized);
return normalized;
}
function withoutObjectProviderOptions(value: unknown): unknown {
if (!isObjectLike(value)) return value;
const { providerOptions: _providerOptions, ...shared } = value;
return shared;
}
function anthropicThinkingBudget(providerOptions: unknown): number | undefined {
if (!isObjectLike(providerOptions)) return undefined;
const anthropic = providerOptions.anthropic;
if (!isObjectLike(anthropic)) return undefined;
const thinking = anthropic.thinking;
if (!isObjectLike(thinking) || thinking.type !== 'enabled') return undefined;
return isNonNegativeSafeInteger(thinking.budgetTokens) ? thinking.budgetTokens : undefined;
}
function isNonNegativeSafeInteger(value: unknown): value is number {
return typeof value === 'number' && Number.isSafeInteger(value) && value >= 0;
}
export function findFirstChangedCacheableSegment(
current: Pick<PreparedProviderRequestCapture, 'segments'>,
prior: Pick<PreparedProviderRequestCapture, 'segments'>,
): PreparedRequestSegmentRef | undefined {
const currentSegments = current.segments.filter((segment) => segment.cacheable);
const priorSegments = prior.segments.filter((segment) => segment.cacheable);
const segmentCount = Math.max(currentSegments.length, priorSegments.length);
for (let position = 0; position < segmentCount; position += 1) {
const currentSegment = currentSegments[position];
const priorSegment = priorSegments[position];
if (
currentSegment?.kind === priorSegment?.kind &&
currentSegment?.index === priorSegment?.index &&
currentSegment?.hash === priorSegment?.hash
) {
continue;
}
const changed = currentSegment ?? priorSegment;
if (!changed) return undefined;
return {
kind: changed.kind,
index: changed.index,
...(changed.role !== undefined ? { role: changed.role } : {}),
};
}
return undefined;
}
/** The provider-visible tools — the active subset actually serialized on the wire. */
function providerVisibleTools(
providerTools: readonly MakaTool[],
activeTools: readonly string[],
): MakaTool[] {
const active = new Set(activeTools);
return providerTools.filter((tool) => active.has(tool.name));
}
function preparedSegment(
kind: PreparedRequestSegmentKind,
index: number,
value: unknown,
cacheable: boolean,
role?: string,
): PreparedRequestSegment {
const serialized = stableStringify(value);
return {
kind,
index,
cacheable,
hash: stableHash(value),
bytes: Buffer.byteLength(serialized, 'utf8'),
...(role !== undefined ? { role } : {}),
};
}
export function stableHash(value: unknown): string {
return `sha256:${createHash('sha256').update(stableStringify(value)).digest('hex')}`;
}
export function stableStringify(value: unknown): string {
return JSON.stringify(canonicalize(value));
}
function classifyDurablePrefixChange(
current: DurablePrefixComponents,
prior: DurablePrefixComponents | undefined,
): PrefixChangeReason {
if (!prior) return 'first_turn';
if (current.modelProviderHash !== prior.modelProviderHash) return 'model_or_provider_changed';
if (current.systemPromptHash !== prior.systemPromptHash) return 'system_prompt_changed';
if (current.toolSchemaHash !== prior.toolSchemaHash) return 'tool_schema_changed';
if (current.providerOptionsHash !== prior.providerOptionsHash) return 'provider_options_changed';
return 'stable';
}
function classifyRequestShapeChange(
current: RequestShapeComponents,
prior: RequestShapeComponents | undefined,
): PrefixChangeReason {
if (!prior) return 'first_turn';
if (current.modelProviderHash !== prior.modelProviderHash) return 'model_or_provider_changed';
if (current.systemPromptHash !== prior.systemPromptHash) return 'system_prompt_changed';
if (current.toolSchemaHash !== prior.toolSchemaHash) return 'tool_schema_changed';
if (current.providerOptionsHash !== prior.providerOptionsHash) return 'provider_options_changed';
if (current.historyProjectionHash !== prior.historyProjectionHash)
return 'history_projection_changed';
return 'stable';
}
function classifyToolSchemaChange(
current: RequestShapeComponents,
prior: RequestShapeComponents | undefined,
currentAvail: ToolAvailabilityDiagnostic | undefined,
priorAvail: ToolAvailabilityDiagnostic | undefined,
): ToolSchemaChangeReason | undefined {
if (!prior || current.toolSchemaHash === prior.toolSchemaHash) return undefined;
if (
isEnabledSourceStrictSuperset(currentAvail, priorAvail) &&
sourceCatalogStable(currentAvail, priorAvail)
) {
return 'tool_source_enabled';
}
if (sourceStateChanged(currentAvail, priorAvail)) {
return 'tool_source_state_changed';
}
return 'tool_schema_changed';
}
function isEnabledSourceStrictSuperset(
current: ToolAvailabilityDiagnostic | undefined,
prior: ToolAvailabilityDiagnostic | undefined,
): boolean {
if (current?.mode !== 'economy' || prior?.mode !== 'economy') return false;
const currentIds = new Set(current.enabledSourceIds);
const priorIds = new Set(prior.enabledSourceIds);
if (currentIds.size <= priorIds.size) return false;
for (const sourceId of priorIds) {
if (!currentIds.has(sourceId)) return false;
}
return true;
}
function sourceCatalogStable(
current: ToolAvailabilityDiagnostic | undefined,
prior: ToolAvailabilityDiagnostic | undefined,
): boolean {
if (!current || !prior) return false;
return (
stableStringify(sourceCatalogShape(current)) === stableStringify(sourceCatalogShape(prior))
);
}
function sourceStateChanged(
current: ToolAvailabilityDiagnostic | undefined,
prior: ToolAvailabilityDiagnostic | undefined,
): boolean {
return stableStringify(current ?? null) !== stableStringify(prior ?? null);
}
function sourceCatalogShape(diagnostic: ToolAvailabilityDiagnostic): unknown {
return {
mode: diagnostic.mode,
connectorToolName: diagnostic.connectorToolName,
visibleToolNamesBySource: diagnostic.visibleToolNamesBySource ?? {},
};
}
function durableComponents(components: RequestShapeComponents): DurablePrefixComponents {
return {
modelProviderHash: components.modelProviderHash,
systemPromptHash: components.systemPromptHash,
providerOptionsHash: components.providerOptionsHash,
toolSchemaHash: components.toolSchemaHash,
};
}
function toolShapeForDiagnostics(tool: MakaTool): unknown {
return {
name: tool.name,
description: tool.description,
inputSchema: schemaShapeForHash(tool.parameters),
...(tool.providerTool ? { providerTool: tool.providerTool } : {}),
};
}
function schemaShapeForHash(schema: unknown): unknown {
if (isObjectLike(schema)) {
try {
return stripJsonSchemaRuntimeFields(
toJSONSchema(schema as never, {
io: 'input',
target: 'draft-07',
unrepresentable: 'any',
cycles: 'ref',
reused: 'inline',
}),
);
} catch {
// Fall through to structural canonicalization for plain JSON-schema-like objects.
}
}
return schema;
}
function stripJsonSchemaRuntimeFields(value: unknown): unknown {
if (Array.isArray(value)) return value.map(stripJsonSchemaRuntimeFields);
if (!isPlainObject(value)) return value;
const out: Record<string, unknown> = {};
for (const [key, entry] of Object.entries(value)) {
if (key === '~standard' || key === '$schema') continue;
out[key] = stripJsonSchemaRuntimeFields(entry);
}
return out;
}
function messageShapeForHash(message: ModelMessage): unknown {
const raw = message as unknown as { role?: unknown; content?: unknown };
return {
role: typeof raw.role === 'string' ? raw.role : 'unknown',
content: contentShapeForHash(raw.content),
};
}
function contentShapeForHash(content: unknown): unknown {
if (typeof content === 'string') {
return { type: 'text', chars: content.length };
}
if (Array.isArray(content)) {
return content.map((part) => {
if (!isObjectLike(part)) return { type: typeof part };
const type = typeof part.type === 'string' ? part.type : 'unknown';
return {
type,
...(typeof part.toolName === 'string' ? { toolName: part.toolName } : {}),
...(typeof part.toolCallId === 'string' ? { toolCallId: part.toolCallId } : {}),
...(typeof part.text === 'string' ? { chars: part.text.length } : {}),
...('output' in part ? { output: payloadShapeForHash(part.output) } : {}),
};
});
}
return { type: typeof content };
}
function payloadShapeForHash(value: unknown): unknown {
const serialized = stableStringify(value);
return {
type: value === null ? 'null' : Array.isArray(value) ? 'array' : typeof value,
chars: serialized.length,
bytes: Buffer.byteLength(serialized, 'utf8'),
hash: stableHash(value),
};
}
function canonicalize(value: unknown, parentKey?: string): unknown {
if (value === null) return null;
if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean')
return value;
if (typeof value === 'bigint') return value.toString();
if (typeof value === 'undefined' || typeof value === 'function' || typeof value === 'symbol') {
return `[${typeof value}]`;
}
if (Array.isArray(value)) {
const items = value.map((item) => canonicalize(item));
return shouldSortArray(parentKey)
? items.slice().sort((a, b) => stableStringify(a).localeCompare(stableStringify(b)))
: items;
}
if (value instanceof Date) return value.toISOString();
if (!isObjectLike(value)) return String(value);
const out: Record<string, unknown> = {};
for (const key of Object.keys(value).sort()) {
out[key] = canonicalize(value[key], key);
}
return out;
}
function shouldSortArray(parentKey: string | undefined): boolean {
return parentKey === 'required' || parentKey === 'enum';
}
function isObjectLike(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null;
}
function isPlainObject(value: unknown): value is Record<string, unknown> {
if (!isObjectLike(value)) return false;
const proto = Object.getPrototypeOf(value);
return proto === Object.prototype || proto === null;
}