blob: 71a624d810c3611af09d4aef92da1d0906ce7c98 [file]
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import {
HISTORY_COMPACT_ROUTES,
MODEL_CALL_DIAGNOSTIC_FIELD_MAX_LENGTH,
MODEL_CALL_ATTEMPT_STATUSES,
MODEL_CALL_COST_BASES,
MODEL_CALL_KINDS,
MODEL_CALL_USAGE_BASES,
type HistoryCompactRoute,
type ModelCallAttemptStatus,
type ModelCallKind,
} from './model-call-attempt.js';
import {
defineObjectShape,
hasExactShape,
isFiniteNumber,
isOptionalString,
isRecord,
} from './record-schema.js';
/**
* Per-session causal trace: what happened inside a session's runs, in order,
* with the cost and latency of the model calls that drove it (#1625).
*
* This is a **projection, not a record**. Two authorities feed it and neither is
* restated here:
*
* - `RuntimeEvent` supplies causal structure — tool dispatch, permission
* decisions, errors, the shape of a turn.
* - `ModelCallAttempt` supplies metering — one record per physical provider
* request, with the cost frozen at call time (#1679).
*
* The projection joins them and reports what it could not see. It never derives
* a number the ledgers do not carry: an unpriced call has no cost here either,
* and a backend that emits no canonical records produces a trace that says so
* rather than one that looks idle.
*/
export const SESSION_TRACE_SCHEMA_VERSION = 1 as const;
/**
* Why a step exists. Deliberately not the RuntimeEvent content kind: this is the
* causal vocabulary a reader thinks in ("the model called a tool, the tool
* failed, the context compacted"), which is a projection of several event
* shapes rather than a rename of one.
*/
export type TraceStepKind = 'model_call' | 'tool' | 'permission' | 'compaction' | 'error';
/** One physical provider request, as metered. */
export interface TraceModelAttempt {
attemptId: string;
/** Retry ordinal within the logical call; 0 is the first dispatch. */
attempt: number;
status: ModelCallAttemptStatus;
startedAt: number;
completedAt: number;
latencyMs: number;
timeToFirstTokenMs?: number;
finishReason?: string;
errorClass?: string;
httpStatus?: number;
providerCode?: string;
providerRequestId?: string;
retryable?: boolean;
inputTokens?: number;
outputTokens?: number;
cacheReadInputTokens?: number;
reasoningTokens?: number;
/**
* The context window the call was metered against, frozen at call time.
* Passed through so a reader can set the prompt size against the ceiling it
* was actually run under, rather than against whatever a catalog says today.
*/
contextWindow?: number;
/**
* Absent when the record carries no price. Never rendered as zero: a call
* nobody could price and a call that was free are different facts, and the
* canonical record keeps them apart (#1679).
*/
costUsd?: number;
/** Whether the amount above is real, and if not, why there isn't one. */
costBasis: 'priced' | 'unpriced';
/** Whether the tokens above were reported by the provider or are partial. */
usageBasis: 'reported' | 'partial' | 'missing';
}
/**
* One logical model call and every attempt of it.
*
* Retries are nested rather than flattened because "this call was retried three
* times" is the fact a reader is looking for; a flat list of four steps that
* happen to share an id makes them reconstruct it.
*/
export interface TraceModelCallStep {
kind: 'model_call';
id: string;
turnId: string;
runId: string;
startedAt: number;
endedAt: number;
durationMs: number;
callKind: ModelCallKind;
/** How a history-compaction call reduced the covered conversation. */
historyCompactRoute?: HistoryCompactRoute;
providerId: string;
modelId: string;
connectionSlug?: string;
/** Runtime tool-loop step index within the turn. */
step: number;
attempts: TraceModelAttempt[];
/** Terminal status of the last attempt. */
status: ModelCallAttemptStatus;
/** Sum over attempts that carry a price; absent when none do. */
costUsd?: number;
}
/**
* A durable recovery decision, correlated to a dispatch by `operationId`.
*
* Separate from the policy below because they answer different questions. Every
* dispatch declares a `recoveryPolicy`, including ordinary first executions;
* only a recovery that actually happened writes one of these.
*/
export interface TraceToolRecovery {
disposition: 'completed' | 'parked';
reasonCode: string;
}
export interface TraceToolStep {
kind: 'tool';
id: string;
turnId: string;
runId: string;
startedAt: number;
endedAt?: number;
durationMs?: number;
toolName: string;
toolCallId?: string;
operationId?: string;
status: 'completed' | 'failed' | 'in_flight';
/**
* What the dispatch declared it would be safe to do on resume. Present on
* normal executions too — it is a policy, not evidence that anything was
* recovered.
*/
recoveryPolicy?: string;
/** Present only when a recovery decision was durably recorded. */
recovered?: TraceToolRecovery;
}
export interface TracePermissionStep {
kind: 'permission';
id: string;
turnId: string;
runId: string;
startedAt: number;
toolName?: string;
decision: string;
}
/**
* A compaction boundary that was durably written — the checkpoint the next
* request replays from.
*
* Distinct from a `history_compact` model call, which is the summarizer request
* that produced the text. One is the spend, the other is the boundary; a turn
* can show either without the other.
*/
export interface TraceCompactionStep {
kind: 'compaction';
id: string;
turnId: string;
runId: string;
startedAt: number;
/** Checkpoint identity, when the boundary carries one. */
checkpointId?: string;
}
export interface TraceErrorStep {
kind: 'error';
id: string;
turnId: string;
runId: string;
startedAt: number;
message: string;
}
export type TraceStep =
| TraceModelCallStep
| TraceToolStep
| TracePermissionStep
| TraceCompactionStep
| TraceErrorStep;
/**
* What ended a turn badly, and what the trace believes caused it.
*
* `attributedToStepId` is a claim about *sequence*, not about intent: the step
* that failed first. The projection does not diagnose; it points at evidence.
*/
export interface TraceFailureAttribution {
code: string;
message?: string;
attributedToStepId?: string;
}
export interface TurnTrace {
turnId: string;
runId: string;
startedAt: number;
endedAt: number;
durationMs: number;
steps: TraceStep[];
failure?: TraceFailureAttribution;
}
export interface TraceTurnIdentity {
runId: string;
turnId: string;
}
const TRACE_TURN_IDENTITY_SHAPE = defineObjectShape<TraceTurnIdentity>()(['runId', 'turnId'], []);
export function traceTurnIdentityKey(identity: TraceTurnIdentity): string {
return `${identity.runId}\0${identity.turnId}`;
}
/**
* What the trace could not see.
*
* Stated per session rather than inferred per view, because incompleteness that
* only shows as an empty timeline is indistinguishable from an idle session —
* and the pi backend produces exactly that: `token_usage` events with no
* canonical records behind them.
*/
export interface SessionTraceCoverage {
/**
* `no_known_gap` — nothing detectable is missing. Deliberately not
* "complete": present settlements cannot prove that every call settled, so
* this states the absence of evidence of a gap, not the presence of proof.
* `partial` — a shortfall is detectable, either a turn with aggregate usage
* and no records, or fewer main calls than the aggregate says it stood for.
* `absent` — model activity with no canonical records anywhere, which is what
* a backend outside canonical accounting looks like.
* `none` — no model activity to cover.
*/
modelCalls: 'no_known_gap' | 'partial' | 'absent' | 'none';
/** Turns with aggregate usage but no canonical record behind them. */
turnsMissingModelCalls: TraceTurnIdentity[];
/**
* Canonical records the reader could not read or decode.
*
* Counted rather than dropped: spend the trace cannot show is the difference
* between a gap that is visible and one that is not. The unit is deliberately
* loose — a run whose events cannot be read at all counts as one, because
* nothing is known about how many records it held. Read it as a floor.
*/
unreadableRecords: number;
/** Runs whose durable evidence exists but exceeds the bounded online view. */
oversizedRuns: number;
/**
* Turns where the aggregate usage stands for more runtime steps than there
* are main model calls on record. A shortfall this narrow is still only what
* the ledgers disagree about — it is a floor on what is missing, not a count.
*/
turnsWithFewerModelCallsThanSteps: TraceTurnIdentity[];
}
export interface SessionTrace {
schemaVersion: typeof SESSION_TRACE_SCHEMA_VERSION;
sessionId: string;
turns: TurnTrace[];
coverage: SessionTraceCoverage;
}
const SESSION_TRACE_SHAPE = defineObjectShape<SessionTrace>()(
['schemaVersion', 'sessionId', 'turns', 'coverage'],
[],
);
const TRACE_COVERAGE_SHAPE = defineObjectShape<SessionTraceCoverage>()(
[
'modelCalls',
'turnsMissingModelCalls',
'unreadableRecords',
'oversizedRuns',
'turnsWithFewerModelCallsThanSteps',
],
[],
);
const TURN_TRACE_SHAPE = defineObjectShape<TurnTrace>()(
['turnId', 'runId', 'startedAt', 'endedAt', 'durationMs', 'steps'],
['failure'],
);
const TRACE_FAILURE_SHAPE = defineObjectShape<TraceFailureAttribution>()(
['code'],
['message', 'attributedToStepId'],
);
const MODEL_CALL_STEP_SHAPE = defineObjectShape<TraceModelCallStep>()(
[
'kind',
'id',
'turnId',
'runId',
'startedAt',
'endedAt',
'durationMs',
'callKind',
'providerId',
'modelId',
'step',
'attempts',
'status',
],
['connectionSlug', 'historyCompactRoute', 'costUsd'],
);
/**
* The Inspector's own view of an attempt. Exported so the projection narrows an
* authority record through this list rather than restating it by hand.
*/
export const MODEL_ATTEMPT_SHAPE = defineObjectShape<TraceModelAttempt>()(
[
'attemptId',
'attempt',
'status',
'startedAt',
'completedAt',
'latencyMs',
'costBasis',
'usageBasis',
],
[
'timeToFirstTokenMs',
'finishReason',
'errorClass',
'httpStatus',
'providerCode',
'providerRequestId',
'retryable',
'inputTokens',
'outputTokens',
'cacheReadInputTokens',
'reasoningTokens',
'contextWindow',
'costUsd',
],
);
const TOOL_STEP_SHAPE = defineObjectShape<TraceToolStep>()(
['kind', 'id', 'turnId', 'runId', 'startedAt', 'toolName', 'status'],
['endedAt', 'durationMs', 'toolCallId', 'operationId', 'recoveryPolicy', 'recovered'],
);
const TOOL_RECOVERY_SHAPE = defineObjectShape<TraceToolRecovery>()(
['disposition', 'reasonCode'],
[],
);
const PERMISSION_STEP_SHAPE = defineObjectShape<TracePermissionStep>()(
['kind', 'id', 'turnId', 'runId', 'startedAt', 'decision'],
['toolName'],
);
const COMPACTION_STEP_SHAPE = defineObjectShape<TraceCompactionStep>()(
['kind', 'id', 'turnId', 'runId', 'startedAt'],
['checkpointId'],
);
const ERROR_STEP_SHAPE = defineObjectShape<TraceErrorStep>()(
['kind', 'id', 'turnId', 'runId', 'startedAt', 'message'],
[],
);
export function isSessionTrace(value: unknown): value is SessionTrace {
return (
isRecord(value) &&
hasExactShape(value, SESSION_TRACE_SHAPE) &&
value.schemaVersion === SESSION_TRACE_SCHEMA_VERSION &&
typeof value.sessionId === 'string' &&
Array.isArray(value.turns) &&
value.turns.every(isTurnTrace) &&
isTraceCoverage(value.coverage)
);
}
export function isTurnTrace(value: unknown): value is TurnTrace {
return (
isRecord(value) &&
hasExactShape(value, TURN_TRACE_SHAPE) &&
typeof value.turnId === 'string' &&
typeof value.runId === 'string' &&
isNonnegativeNumber(value.startedAt) &&
isNonnegativeNumber(value.endedAt) &&
isNonnegativeNumber(value.durationMs) &&
Array.isArray(value.steps) &&
value.steps.every(isTraceStep) &&
(value.failure === undefined || isTraceFailure(value.failure))
);
}
function isTraceCoverage(value: unknown): value is SessionTraceCoverage {
return (
isRecord(value) &&
hasExactShape(value, TRACE_COVERAGE_SHAPE) &&
(value.modelCalls === 'no_known_gap' ||
value.modelCalls === 'partial' ||
value.modelCalls === 'absent' ||
value.modelCalls === 'none') &&
isTraceTurnIdentityArray(value.turnsMissingModelCalls) &&
isTraceTurnIdentityArray(value.turnsWithFewerModelCallsThanSteps) &&
isNonnegativeInteger(value.unreadableRecords) &&
isNonnegativeInteger(value.oversizedRuns)
);
}
function isTraceFailure(value: unknown): value is TraceFailureAttribution {
return (
isRecord(value) &&
hasExactShape(value, TRACE_FAILURE_SHAPE) &&
typeof value.code === 'string' &&
isOptionalString(value.message) &&
isOptionalString(value.attributedToStepId)
);
}
function isTraceStep(value: unknown): value is TraceStep {
if (!isRecord(value)) return false;
if (value.kind === 'model_call') return isModelCallStep(value);
if (value.kind === 'tool') return isToolStep(value);
const common =
typeof value.id === 'string' &&
typeof value.turnId === 'string' &&
typeof value.runId === 'string' &&
isNonnegativeNumber(value.startedAt);
if (value.kind === 'permission') {
return (
hasExactShape(value, PERMISSION_STEP_SHAPE) &&
common &&
isOptionalString(value.toolName) &&
typeof value.decision === 'string'
);
}
if (value.kind === 'compaction') {
return (
hasExactShape(value, COMPACTION_STEP_SHAPE) && common && isOptionalString(value.checkpointId)
);
}
return (
value.kind === 'error' &&
hasExactShape(value, ERROR_STEP_SHAPE) &&
common &&
typeof value.message === 'string'
);
}
function isModelCallStep(value: Record<string, unknown>): boolean {
return (
hasExactShape(value, MODEL_CALL_STEP_SHAPE) &&
[value.id, value.turnId, value.runId, value.providerId, value.modelId].every(
(item) => typeof item === 'string',
) &&
[value.startedAt, value.endedAt, value.durationMs].every(isNonnegativeNumber) &&
MODEL_CALL_KINDS.includes(value.callKind as ModelCallKind) &&
(value.historyCompactRoute === undefined ||
(value.callKind === 'history_compact' &&
HISTORY_COMPACT_ROUTES.includes(value.historyCompactRoute as HistoryCompactRoute))) &&
isOptionalString(value.connectionSlug) &&
isNonnegativeInteger(value.step) &&
Array.isArray(value.attempts) &&
value.attempts.every(isModelAttempt) &&
MODEL_CALL_ATTEMPT_STATUSES.includes(value.status as ModelCallAttemptStatus) &&
isOptionalNonnegativeNumber(value.costUsd)
);
}
function isModelAttempt(value: unknown): value is TraceModelAttempt {
return (
isRecord(value) &&
hasExactShape(value, MODEL_ATTEMPT_SHAPE) &&
typeof value.attemptId === 'string' &&
isNonnegativeInteger(value.attempt) &&
MODEL_CALL_ATTEMPT_STATUSES.includes(value.status as ModelCallAttemptStatus) &&
[value.startedAt, value.completedAt, value.latencyMs].every(isNonnegativeNumber) &&
[
value.timeToFirstTokenMs,
value.inputTokens,
value.outputTokens,
value.cacheReadInputTokens,
value.reasoningTokens,
value.contextWindow,
value.costUsd,
].every(isOptionalNonnegativeNumber) &&
isOptionalString(value.finishReason) &&
isOptionalString(value.errorClass) &&
isOptionalHttpStatus(value.httpStatus) &&
isOptionalDiagnosticString(value.providerCode) &&
isOptionalDiagnosticString(value.providerRequestId) &&
(value.retryable === undefined || typeof value.retryable === 'boolean') &&
MODEL_CALL_COST_BASES.includes(value.costBasis as TraceModelAttempt['costBasis']) &&
MODEL_CALL_USAGE_BASES.includes(value.usageBasis as TraceModelAttempt['usageBasis'])
);
}
function isOptionalHttpStatus(value: unknown): boolean {
return (
value === undefined ||
(Number.isInteger(value) && (value as number) >= 100 && (value as number) <= 599)
);
}
function isOptionalDiagnosticString(value: unknown): boolean {
return (
value === undefined ||
(typeof value === 'string' &&
value.length > 0 &&
value.length <= MODEL_CALL_DIAGNOSTIC_FIELD_MAX_LENGTH)
);
}
function isToolStep(value: Record<string, unknown>): boolean {
return (
hasExactShape(value, TOOL_STEP_SHAPE) &&
[value.id, value.turnId, value.runId, value.toolName].every(
(item) => typeof item === 'string',
) &&
isNonnegativeNumber(value.startedAt) &&
isOptionalNonnegativeNumber(value.endedAt) &&
isOptionalNonnegativeNumber(value.durationMs) &&
[value.toolCallId, value.operationId, value.recoveryPolicy].every(isOptionalString) &&
(value.status === 'completed' || value.status === 'failed' || value.status === 'in_flight') &&
(value.recovered === undefined || isToolRecovery(value.recovered))
);
}
function isToolRecovery(value: unknown): value is TraceToolRecovery {
return (
isRecord(value) &&
hasExactShape(value, TOOL_RECOVERY_SHAPE) &&
(value.disposition === 'completed' || value.disposition === 'parked') &&
typeof value.reasonCode === 'string'
);
}
function isNonnegativeInteger(value: unknown): value is number {
return Number.isSafeInteger(value) && (value as number) >= 0;
}
function isNonnegativeNumber(value: unknown): value is number {
return isFiniteNumber(value) && value >= 0;
}
function isOptionalNonnegativeNumber(value: unknown): boolean {
return value === undefined || isNonnegativeNumber(value);
}
/**
* Combines coverage from disjoint trace partitions.
*
* Callers own the disjointness proof. Keeping that precondition explicit is
* what makes unreadable-record addition truthful: refreshing one partition
* must replace it before this fold runs, never merge it with its former value.
*/
export function mergeDisjointTraceCoverage(
base: SessionTraceCoverage,
next: SessionTraceCoverage,
): SessionTraceCoverage {
const modelCalls =
base.modelCalls === 'none'
? next.modelCalls
: next.modelCalls === 'none'
? base.modelCalls
: base.modelCalls === 'absent' && next.modelCalls === 'absent'
? 'absent'
: base.modelCalls === 'no_known_gap' && next.modelCalls === 'no_known_gap'
? 'no_known_gap'
: 'partial';
return {
modelCalls,
turnsMissingModelCalls: mergeTraceTurnIdentities(
base.turnsMissingModelCalls,
next.turnsMissingModelCalls,
),
turnsWithFewerModelCallsThanSteps: mergeTraceTurnIdentities(
base.turnsWithFewerModelCallsThanSteps,
next.turnsWithFewerModelCallsThanSteps,
),
unreadableRecords: base.unreadableRecords + next.unreadableRecords,
oversizedRuns: base.oversizedRuns + next.oversizedRuns,
};
}
/**
* Combines independently read pages of one Session trace.
*
* Page boundaries are transport detail: callers receive the same ordered,
* deduplicated trace they would have received from one bounded projection.
*/
export function mergeSessionTraces(traces: readonly SessionTrace[]): SessionTrace {
const first = traces[0];
if (!first) throw new Error('At least one Session trace page is required');
return traces.slice(1).reduce((current, page) => {
if (page.schemaVersion !== current.schemaVersion || page.sessionId !== current.sessionId) {
throw new Error('Session trace pages do not describe the same Session');
}
const turns = new Map(current.turns.map((turn) => [traceTurnIdentityKey(turn), turn] as const));
for (const turn of page.turns) turns.set(traceTurnIdentityKey(turn), turn);
const ordered = [...turns.values()].sort(
(left, right) =>
left.startedAt - right.startedAt ||
left.runId.localeCompare(right.runId) ||
left.turnId.localeCompare(right.turnId),
);
return {
schemaVersion: current.schemaVersion,
sessionId: current.sessionId,
turns: ordered,
coverage: mergeDisjointTraceCoverage(current.coverage, page.coverage),
};
}, first);
}
function isTraceTurnIdentityArray(value: unknown): value is TraceTurnIdentity[] {
return (
Array.isArray(value) &&
value.every(
(identity) =>
isRecord(identity) &&
hasExactShape(identity, TRACE_TURN_IDENTITY_SHAPE) &&
typeof identity.runId === 'string' &&
identity.runId.length > 0 &&
typeof identity.turnId === 'string' &&
identity.turnId.length > 0,
)
);
}
function mergeTraceTurnIdentities(
base: readonly TraceTurnIdentity[],
next: readonly TraceTurnIdentity[],
): TraceTurnIdentity[] {
return [
...new Map(
[...base, ...next].map((identity) => [traceTurnIdentityKey(identity), identity]),
).values(),
];
}