assistant-cloud
Version:
Cloud integration for assistant-ui
545 lines (511 loc) • 18.7 kB
text/typescript
import type { SamplingCallData } from "./instrumentMcpSampling";
import type { AssistantCloudRunReport } from "./AssistantCloudRuns";
const MAX_TELEMETRY_TEXT_LENGTH = 50_000;
const BASE64_PATTERN = /^[A-Za-z0-9+/]+={0,2}$/;
export type AssistantCloudRunReportToolCall = {
tool_name: string;
tool_call_id: string;
tool_args?: string;
tool_result?: string;
tool_source?: "mcp" | "frontend" | "backend";
start_ms?: number;
end_ms?: number;
sampling_calls?: SamplingCallData[];
};
export type RunReportOutcome =
| "aborted"
| "disconnected"
| "length"
| "content_filter"
| "rate_limited"
| "validation_failed"
| "server_error"
| "persistence_error";
/**
* Maps a finish event to the report status and outcome. `fallbackStatus`
* applies when the event carries neither a finish reason nor a failure flag.
*/
export function deriveRunOutcome(
input: {
finishReason?: string | undefined;
isAbort?: boolean | undefined;
isDisconnect?: boolean | undefined;
isError?: boolean | undefined;
},
fallbackStatus: "completed" | "incomplete" = "completed",
): {
status: "completed" | "incomplete" | "error";
outcome?: RunReportOutcome;
} {
if (input.isError) return { status: "error" };
if (input.isAbort) return { status: "incomplete", outcome: "aborted" };
if (input.isDisconnect) {
return { status: "incomplete", outcome: "disconnected" };
}
switch (input.finishReason) {
case "length":
return { status: "incomplete", outcome: "length" };
case "content-filter":
case "content_filter":
return { status: "incomplete", outcome: "content_filter" };
case "cancelled":
return { status: "incomplete", outcome: "aborted" };
case "error":
return { status: "error" };
default:
return {
status: input.finishReason === undefined ? fallbackStatus : "completed",
};
}
}
const MAX_RUN_ERROR_CODE_LENGTH = 64;
const MAX_RUN_ERROR_LENGTH = 2048;
/**
* Reads the message, code and outcome the runs endpoint stores for a failed
* run. The code is the error's HTTP `statusCode` when it carries one, else its
* `code`, else its class name. The outcome comes from the HTTP status.
*/
export function describeRunError(error: unknown): {
error?: string;
errorCode?: string;
outcome?: RunReportOutcome;
} {
if (error == null) return {};
const record =
typeof error === "object" ? (error as Record<string, unknown>) : undefined;
const message =
typeof error === "string"
? error
: typeof record?.message === "string"
? record.message
: undefined;
const statusCode =
typeof record?.statusCode === "number" &&
Number.isInteger(record.statusCode)
? record.statusCode
: undefined;
const code =
statusCode !== undefined
? String(statusCode)
: typeof record?.code === "string"
? record.code
: typeof record?.name === "string" && record.name !== "Error"
? record.name
: undefined;
const outcome =
statusCode !== undefined ? outcomeForStatusCode(statusCode) : undefined;
return {
...(message
? { error: message.slice(0, MAX_RUN_ERROR_LENGTH) }
: undefined),
...(code
? { errorCode: code.slice(0, MAX_RUN_ERROR_CODE_LENGTH) }
: undefined),
...(outcome ? { outcome } : undefined),
};
}
const outcomeForStatusCode = (
statusCode: number,
): RunReportOutcome | undefined => {
if (statusCode === 429) return "rate_limited";
if (statusCode === 400 || statusCode === 422) return "validation_failed";
if (statusCode >= 500 && statusCode < 600) return "server_error";
return undefined;
};
/**
* Clamps a string to the size the runs endpoint accepts for a single span
* field.
*/
export function truncateRunTelemetryText(value: string): string {
if (value.length <= MAX_TELEMETRY_TEXT_LENGTH) return value;
return value.slice(0, MAX_TELEMETRY_TEXT_LENGTH);
}
function safeStringify(value: unknown): string | undefined {
if (value == null) return undefined;
try {
return truncateRunTelemetryText(JSON.stringify(value));
} catch {
return undefined;
}
}
const base64SizeKB = (value: string) =>
((value.length * 3) / 4 / 1024).toFixed(1);
// Both call sites read a field the MCP content grammar already defines as
// base64, so the test only has to recognise the encoding's own shape: its
// alphabet, and a length that is a multiple of four. A size floor would leave
// a short payload — `aGk=` is a whole image in this repo's own fixtures —
// serialized raw.
const isInlineBase64 = (value: unknown): value is string => {
if (typeof value !== "string") return false;
const head = value.slice(0, 200);
return head.length > 0 && head.length % 4 === 0 && BASE64_PATTERN.test(head);
};
function summarizeMcpContentBlock(item: unknown): unknown {
if (!item || typeof item !== "object") return item;
const block = item as {
type?: unknown;
data?: unknown;
resource?: unknown;
};
if (
(block.type === "image" || block.type === "audio") &&
isInlineBase64(block.data)
) {
return {
...block,
data: `[${String(block.type)}: ${base64SizeKB(block.data)}KB]`,
};
}
// An EmbeddedResource is the third inline carrier: a binary resource arrives
// as base64 under `resource.blob` rather than as a top-level `data` field.
if (
block.type === "resource" &&
block.resource &&
typeof block.resource === "object"
) {
const resource = block.resource as { blob?: unknown };
if (isInlineBase64(resource.blob)) {
return {
...block,
resource: {
...resource,
blob: `[resource: ${base64SizeKB(resource.blob)}KB]`,
},
};
}
}
return item;
}
function summarizeMcpResult(value: unknown): string | undefined {
if (value == null) return undefined;
try {
const parsed = typeof value === "string" ? JSON.parse(value) : value;
if (Array.isArray(parsed)) {
return truncateRunTelemetryText(
JSON.stringify(parsed.map(summarizeMcpContentBlock)),
);
}
// `callTool` resolves to a CallToolResult, so the blocks carrying base64
// arrive under `content` rather than as the result itself.
if (typeof parsed === "object") {
const content = (parsed as { content?: unknown }).content;
if (Array.isArray(content)) {
return truncateRunTelemetryText(
JSON.stringify({
...(parsed as Record<string, unknown>),
content: content.map(summarizeMcpContentBlock),
}),
);
}
}
} catch {
// not a shape with summarizable content, fall through
}
return safeStringify(value);
}
export type RunTelemetryToolCallInit = {
toolName: string;
toolCallId: string;
args?: unknown;
/**
* Pre-serialized arguments, used in place of serializing `args`. Values over
* the span size are clamped before they are included in the report.
*/
argsText?: string | undefined;
result?: unknown;
toolSource?: "mcp" | "frontend" | "backend" | undefined;
};
/**
* Serializes one tool call into the shape the runs endpoint accepts. An `mcp`
* source has its result summarized, because MCP content blocks carry inline
* base64 image and audio payloads that would otherwise dominate the report.
*/
export function createRunTelemetryToolCall(
init: RunTelemetryToolCallInit,
): AssistantCloudRunReportToolCall {
const { toolName, toolCallId, args, argsText, result, toolSource } = init;
const call: AssistantCloudRunReportToolCall = {
tool_name: toolName,
tool_call_id: toolCallId,
};
const toolArgs =
argsText != null ? truncateRunTelemetryText(argsText) : safeStringify(args);
if (toolArgs !== undefined) call.tool_args = toolArgs;
const toolResult =
toolSource === "mcp" ? summarizeMcpResult(result) : safeStringify(result);
if (toolResult !== undefined) call.tool_result = toolResult;
if (toolSource) call.tool_source = toolSource;
return call;
}
/**
* Resolves the model ID a run reports, in the order an app can supply it: an
* explicit `modelId`, the `custom` bag, then the per-step `response.modelId`
* that a `messageMetadata` callback copies off the AI SDK's finish-step part.
* The AI SDK puts no model ID on a UI message part, so message metadata is the
* only channel one arrives on.
*/
export function extractRunTelemetryModelId(
metadata: Record<string, unknown> | undefined,
): string | undefined {
if (!metadata) return undefined;
if (typeof metadata.modelId === "string") return metadata.modelId;
const custom = metadata.custom as Record<string, unknown> | undefined;
if (typeof custom?.modelId === "string") return custom.modelId;
const steps: unknown = metadata.steps;
if (!Array.isArray(steps)) return undefined;
for (const step of steps as unknown[]) {
if (!step || typeof step !== "object") continue;
const response = (step as Record<string, unknown>).response;
if (!response || typeof response !== "object") continue;
const modelId = (response as Record<string, unknown>).modelId;
if (typeof modelId === "string") return modelId;
}
return undefined;
}
export type RunTelemetryUsage = {
inputTokens?: number | undefined;
outputTokens?: number | undefined;
reasoningTokens?: number | undefined;
cachedInputTokens?: number | undefined;
};
export type RunTelemetryUsageInit = RunTelemetryUsage & {
promptTokens?: number | undefined;
completionTokens?: number | undefined;
inputTokenDetails?: { cacheReadTokens?: number };
outputTokenDetails?: { reasoningTokens?: number };
};
export type RunReportStepInit = {
usage?: RunTelemetryUsageInit | undefined;
toolCalls?: AssistantCloudRunReportToolCall[] | undefined;
startMs?: number | undefined;
endMs?: number | undefined;
finishReason?: string | undefined;
input?: string | undefined;
};
/**
* The run report fields read from the messages of one run, in whichever
* format they were stored: the status the messages imply, the tool calls, the
* steps, the text, the usage and the model.
*/
export type RunMessageTelemetry = {
assistantMessageId?: string;
status: "completed" | "incomplete";
toolCalls?: AssistantCloudRunReportToolCall[];
steps?: RunReportStepInit[];
totalSteps?: number;
outputText?: string;
usage?: RunTelemetryUsage;
modelId?: string;
metadata?: Record<string, unknown>;
};
export type RunReportInit = {
threadId: string;
status: AssistantCloudRunReport["status"];
outcome?: AssistantCloudRunReport["outcome_type"] | undefined;
errorCode?: string | undefined;
error?: string | undefined;
messageId?: string | undefined;
userMessageId?: string | undefined;
traceId?: string | undefined;
rootSpanId?: string | undefined;
modelId?: string | undefined;
provider?: string | undefined;
usage?: RunTelemetryUsageInit | undefined;
steps?: RunReportStepInit[] | undefined;
totalSteps?: number | undefined;
toolCalls?: AssistantCloudRunReportToolCall[] | undefined;
durationMs?: number | undefined;
firstTokenMs?: number | undefined;
costUsd?: number | undefined;
costDetails?:
| {
input?: number | undefined;
inputCachedTokens?: number | undefined;
output?: number | undefined;
total?: number | undefined;
}
| undefined;
outputText?: string | undefined;
attributes?: Record<string, unknown> | undefined;
metadata?: Record<string, unknown> | undefined;
telemetry?: {
environment?: string | undefined;
release?: string | undefined;
tags?: readonly string[] | undefined;
};
};
function assignUsage(
report: AssistantCloudRunReport,
usage: RunTelemetryUsageInit | undefined,
): void {
if (!usage) return;
const normalized = normalizeRunTelemetryUsage(usage);
if (!normalized) return;
if (normalized.inputTokens !== undefined) {
report.input_tokens = normalized.inputTokens;
}
if (normalized.outputTokens !== undefined) {
report.output_tokens = normalized.outputTokens;
}
if (normalized.reasoningTokens !== undefined) {
report.reasoning_tokens = normalized.reasoningTokens;
}
if (normalized.cachedInputTokens !== undefined) {
report.cached_input_tokens = normalized.cachedInputTokens;
}
}
function createRunReportStep(
init: RunReportStepInit,
): NonNullable<AssistantCloudRunReport["steps"]>[number] {
const step: NonNullable<AssistantCloudRunReport["steps"]>[number] = {};
const usage = init.usage ? normalizeRunTelemetryUsage(init.usage) : undefined;
if (usage?.inputTokens !== undefined) step.input_tokens = usage.inputTokens;
if (usage?.outputTokens !== undefined)
step.output_tokens = usage.outputTokens;
if (usage?.reasoningTokens !== undefined) {
step.reasoning_tokens = usage.reasoningTokens;
}
if (usage?.cachedInputTokens !== undefined) {
step.cached_input_tokens = usage.cachedInputTokens;
}
if (init.toolCalls !== undefined) step.tool_calls = init.toolCalls;
if (init.startMs !== undefined) step.start_ms = init.startMs;
if (init.endMs !== undefined) step.end_ms = init.endMs;
if (init.finishReason !== undefined) {
step.finish_reason = init.finishReason.slice(0, 32);
}
if (init.input !== undefined) {
step.input = truncateRunTelemetryText(init.input);
}
return step;
}
function normalizeRunReportTags(
tags: readonly string[] | undefined,
): string[] | undefined {
if (!tags) return undefined;
const result: string[] = [];
const seen = new Set<string>();
for (const tag of tags) {
const normalized = tag.trim().slice(0, 64).trim();
if (!normalized || seen.has(normalized)) continue;
seen.add(normalized);
result.push(normalized);
if (result.length === 20) break;
}
return result.length > 0 ? result : undefined;
}
function normalizeRunReportMilliseconds(
value: number | undefined,
): number | undefined {
if (value === undefined || !Number.isFinite(value)) return undefined;
return Math.max(0, Math.round(value));
}
function normalizeRunReportCost(value: number | undefined): number | undefined {
if (value === undefined || !Number.isFinite(value) || value < 0) {
return undefined;
}
return value;
}
function assignCostDetails(
report: AssistantCloudRunReport,
costDetails: RunReportInit["costDetails"],
): void {
if (!costDetails) return;
const normalized: NonNullable<AssistantCloudRunReport["cost_details"]> = {};
const input = normalizeRunReportCost(costDetails.input);
if (input !== undefined) normalized.input = input;
const inputCachedTokens = normalizeRunReportCost(
costDetails.inputCachedTokens,
);
if (inputCachedTokens !== undefined) {
normalized.input_cached_tokens = inputCachedTokens;
}
const output = normalizeRunReportCost(costDetails.output);
if (output !== undefined) normalized.output = output;
const total = normalizeRunReportCost(costDetails.total);
if (total !== undefined) normalized.total = total;
if (Object.keys(normalized).length > 0) report.cost_details = normalized;
}
export function createRunReport(init: RunReportInit): AssistantCloudRunReport {
const report: AssistantCloudRunReport = {
thread_id: init.threadId,
status: init.status,
};
const traceId = init.traceId?.toLowerCase();
if (traceId && /^[0-9a-f]{32}$/.test(traceId)) report.trace_id = traceId;
const rootSpanId = init.rootSpanId?.toLowerCase();
if (rootSpanId && /^[0-9a-f]{16}$/.test(rootSpanId)) {
report.root_span_id = rootSpanId;
}
if (init.outcome !== undefined) report.outcome_type = init.outcome;
if (init.errorCode !== undefined) report.error_code = init.errorCode;
if (init.error !== undefined) report.error = init.error;
if (init.messageId !== undefined) report.message_id = init.messageId;
if (init.userMessageId !== undefined)
report.user_message_id = init.userMessageId;
if (init.modelId !== undefined) report.model_id = init.modelId;
if (init.provider !== undefined) {
report.provider = init.provider;
report.provider_type = init.provider;
}
assignUsage(report, init.usage);
if (init.steps !== undefined) {
report.steps = init.steps.map(createRunReportStep);
report.total_steps = init.steps.length;
} else if (init.totalSteps !== undefined) {
report.total_steps = init.totalSteps;
}
if (init.toolCalls !== undefined) report.tool_calls = init.toolCalls;
const durationMs = normalizeRunReportMilliseconds(init.durationMs);
if (durationMs !== undefined) report.duration_ms = durationMs;
const firstTokenMs = normalizeRunReportMilliseconds(init.firstTokenMs);
if (firstTokenMs !== undefined) report.first_token_ms = firstTokenMs;
const costUsd = normalizeRunReportCost(init.costUsd);
if (costUsd !== undefined) report.cost_usd = costUsd;
assignCostDetails(report, init.costDetails);
if (init.outputText !== undefined) {
report.output_text = truncateRunTelemetryText(init.outputText);
}
if (init.attributes !== undefined) report.attributes = init.attributes;
if (init.metadata !== undefined) report.metadata = init.metadata;
if (init.telemetry?.environment !== undefined) {
report.environment = init.telemetry.environment;
}
if (init.telemetry?.release !== undefined) {
report.release = init.telemetry.release;
}
const tags = normalizeRunReportTags(init.telemetry?.tags);
if (tags !== undefined) report.tags = tags;
return report;
}
/**
* Resolves the token counts a provider reports under any of the names the AI
* SDK has used: the current top-level ones, the legacy prompt/completion pair,
* and the v7 token detail objects. Returns undefined when no count is present,
* so callers can tell an empty usage object from a zeroed one.
*/
export function normalizeRunTelemetryUsage(
usage: RunTelemetryUsageInit,
): RunTelemetryUsage | undefined {
const inputTokens = usage.inputTokens ?? usage.promptTokens;
const outputTokens = usage.outputTokens ?? usage.completionTokens;
// AI SDK v7 moved these under token detail objects; v6 kept them top-level.
const reasoningTokens =
usage.reasoningTokens ?? usage.outputTokenDetails?.reasoningTokens;
const cachedInputTokens =
usage.cachedInputTokens ?? usage.inputTokenDetails?.cacheReadTokens;
if (
inputTokens == null &&
outputTokens == null &&
reasoningTokens == null &&
cachedInputTokens == null
) {
return undefined;
}
return {
...(inputTokens != null ? { inputTokens } : undefined),
...(outputTokens != null ? { outputTokens } : undefined),
...(reasoningTokens != null ? { reasoningTokens } : undefined),
...(cachedInputTokens != null ? { cachedInputTokens } : undefined),
};
}