chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,497 @@
|
||||
import { ChevronDownIcon, ChevronUpIcon } from "@heroicons/react/20/solid";
|
||||
import { Clipboard, ClipboardCheck } from "lucide-react";
|
||||
import { Suspense, useEffect, useState } from "react";
|
||||
import { CodeSquareIcon } from "~/assets/icons/CodeSquareIcon";
|
||||
import { TextSquareIcon } from "~/assets/icons/TextSquareIcon";
|
||||
import { CodeBlock } from "~/components/code/CodeBlock";
|
||||
import { StreamdownRenderer } from "~/components/code/StreamdownRenderer";
|
||||
import { Button, LinkButton } from "~/components/primitives/Buttons";
|
||||
import { Header3 } from "~/components/primitives/Headers";
|
||||
import tablerSpritePath from "~/components/primitives/tabler-sprite.svg";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "~/components/primitives/Tooltip";
|
||||
import { cn } from "~/utils/cn";
|
||||
import type { DisplayItem, ToolUse } from "./types";
|
||||
|
||||
export type PromptLink = {
|
||||
slug: string;
|
||||
version?: string;
|
||||
path: string;
|
||||
};
|
||||
|
||||
export function AIChatMessages({
|
||||
items,
|
||||
promptLink,
|
||||
}: {
|
||||
items: DisplayItem[];
|
||||
promptLink?: PromptLink;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-col gap-1">
|
||||
{items.map((item, i) => {
|
||||
switch (item.type) {
|
||||
case "system":
|
||||
return <SystemSection key={i} text={item.text} promptLink={promptLink} />;
|
||||
case "user":
|
||||
return <UserSection key={i} text={item.text} />;
|
||||
case "tool-use":
|
||||
return <ToolUseSection key={i} tools={item.tools} />;
|
||||
case "assistant":
|
||||
return <AssistantResponse key={i} text={item.text} />;
|
||||
}
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Section header (shared across all sections)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function SectionHeader({ label, right }: { label: string; right?: React.ReactNode }) {
|
||||
return (
|
||||
<div className="flex items-center justify-between">
|
||||
<Header3>{label}</Header3>
|
||||
{right && <div className="flex items-center gap-2">{right}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ChatBubble({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="rounded-md border border-grid-bright bg-background-hover/50 px-3.5 py-2">
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// System
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function SystemSection({ text, promptLink }: { text: string; promptLink?: PromptLink }) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const isLong = text.length > 150;
|
||||
const preview = isLong ? text.slice(0, 150) + "..." : text;
|
||||
const displayText = expanded || !isLong ? text : preview;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-1.5 py-2.5">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Header3>System</Header3>
|
||||
{promptLink && (
|
||||
<LinkButton to={promptLink.path} variant="minimal/small">
|
||||
<span className="flex items-center gap-1">
|
||||
<svg className="size-3.5 shrink-0 text-text-dimmed">
|
||||
<use xlinkHref={`${tablerSpritePath}#tabler-file-text-ai`} />
|
||||
</svg>
|
||||
{promptLink.slug}
|
||||
{promptLink.version ? ` v${promptLink.version}` : ""}
|
||||
</span>
|
||||
</LinkButton>
|
||||
)}
|
||||
</div>
|
||||
{isLong && (
|
||||
<Button
|
||||
variant="minimal/small"
|
||||
onClick={() => setExpanded(!expanded)}
|
||||
LeadingIcon={expanded ? ChevronUpIcon : ChevronDownIcon}
|
||||
aria-label={expanded ? "Collapse" : "Expand"}
|
||||
aria-expanded={expanded}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<ChatBubble>
|
||||
<div className="streamdown-container font-sans text-sm font-normal text-text-dimmed">
|
||||
<Suspense fallback={<span className="whitespace-pre-wrap">{displayText}</span>}>
|
||||
<StreamdownRenderer>{displayText}</StreamdownRenderer>
|
||||
</Suspense>
|
||||
</div>
|
||||
</ChatBubble>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// User
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function UserSection({ text }: { text: string }) {
|
||||
return (
|
||||
<div className="flex flex-col gap-1.5 py-2.5">
|
||||
<SectionHeader label="User" />
|
||||
<ChatBubble>
|
||||
<div className="streamdown-container font-sans text-sm font-normal text-text-dimmed">
|
||||
<Suspense fallback={<span className="whitespace-pre-wrap">{text}</span>}>
|
||||
<StreamdownRenderer>{text}</StreamdownRenderer>
|
||||
</Suspense>
|
||||
</div>
|
||||
</ChatBubble>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Assistant response (with markdown/raw toggle)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function isJsonString(value: string): boolean {
|
||||
const trimmed = value.trimStart();
|
||||
if (!trimmed.startsWith("{") && !trimmed.startsWith("[")) return false;
|
||||
try {
|
||||
JSON.parse(value);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function AssistantResponse({
|
||||
text,
|
||||
headerLabel = "Assistant",
|
||||
}: {
|
||||
text: string;
|
||||
headerLabel?: string;
|
||||
}) {
|
||||
const isJson = isJsonString(text);
|
||||
const [mode, setMode] = useState<"rendered" | "raw">("rendered");
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
function handleCopy() {
|
||||
navigator.clipboard.writeText(text);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
}
|
||||
|
||||
if (isJson) {
|
||||
return (
|
||||
<div className="flex flex-col gap-1.5 py-2.5">
|
||||
<SectionHeader label={headerLabel} />
|
||||
<CodeBlock
|
||||
code={text}
|
||||
maxLines={20}
|
||||
showLineNumbers={false}
|
||||
showCopyButton
|
||||
language="json"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-1.5 py-2.5">
|
||||
<SectionHeader
|
||||
label={headerLabel}
|
||||
right={
|
||||
<div className="flex items-center gap-2">
|
||||
<TooltipProvider>
|
||||
<Tooltip disableHoverableContent>
|
||||
<TooltipTrigger
|
||||
onClick={() => setMode(mode === "rendered" ? "raw" : "rendered")}
|
||||
className="text-text-dimmed transition-colors duration-100 focus-custom hover:cursor-pointer hover:text-text-bright"
|
||||
>
|
||||
{mode === "rendered" ? (
|
||||
<CodeSquareIcon className="size-4.5" />
|
||||
) : (
|
||||
<TextSquareIcon className="size-4.5" />
|
||||
)}
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" className="text-xs">
|
||||
{mode === "rendered" ? "Show raw" : "Show rendered"}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
<TooltipProvider>
|
||||
<Tooltip open={copied || undefined} disableHoverableContent>
|
||||
<TooltipTrigger
|
||||
onClick={handleCopy}
|
||||
className={cn(
|
||||
"transition-colors duration-100 focus-custom hover:cursor-pointer",
|
||||
copied ? "text-success" : "text-text-dimmed hover:text-text-bright"
|
||||
)}
|
||||
>
|
||||
{copied ? (
|
||||
<ClipboardCheck className="size-4" />
|
||||
) : (
|
||||
<Clipboard className="size-4" />
|
||||
)}
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" className="text-xs">
|
||||
{copied ? "Copied" : "Copy"}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
{mode === "rendered" ? (
|
||||
<ChatBubble>
|
||||
<div className="streamdown-container min-w-0 font-sans text-sm font-normal text-text-dimmed wrap-anywhere">
|
||||
<Suspense fallback={<span className="whitespace-pre-wrap">{text}</span>}>
|
||||
<StreamdownRenderer>{text}</StreamdownRenderer>
|
||||
</Suspense>
|
||||
</div>
|
||||
</ChatBubble>
|
||||
) : (
|
||||
<CodeBlock
|
||||
code={text}
|
||||
maxLines={20}
|
||||
showLineNumbers={false}
|
||||
showCopyButton={false}
|
||||
className="pl-2"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tool use (merged calls + results)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function ToolUseSection({ tools }: { tools: ToolUse[] }) {
|
||||
return (
|
||||
<div className="flex flex-col gap-1.5 py-2.5">
|
||||
<SectionHeader label={tools.length === 1 ? "Tool call" : `Tool calls (${tools.length})`} />
|
||||
<div className="flex flex-col gap-2">
|
||||
{tools.map((tool) => (
|
||||
<ToolUseRow key={tool.toolCallId} tool={tool} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type ToolTab = "input" | "output" | "details" | "agent";
|
||||
|
||||
export function ToolUseRow({ tool }: { tool: ToolUse }) {
|
||||
const hasInput = tool.inputJson !== "{}";
|
||||
const hasResult = !!tool.resultOutput;
|
||||
const hasDetails = !!tool.description || !!tool.parametersJson;
|
||||
const hasSubAgent = !!tool.subAgent;
|
||||
|
||||
const availableTabs: ToolTab[] = [
|
||||
...(hasSubAgent ? (["agent"] as const) : []),
|
||||
...(hasInput ? (["input"] as const) : []),
|
||||
...(hasResult ? (["output"] as const) : []),
|
||||
...(hasDetails ? (["details"] as const) : []),
|
||||
];
|
||||
|
||||
const [activeTab, setActiveTab] = useState<ToolTab | null>(
|
||||
hasSubAgent ? "agent" : hasInput ? "input" : null
|
||||
);
|
||||
|
||||
// Auto-select input tab when input arrives after initial render (e.g. streaming tool calls)
|
||||
useEffect(() => {
|
||||
if (!hasSubAgent && hasInput && activeTab === null) {
|
||||
setActiveTab("input");
|
||||
}
|
||||
}, [hasInput, hasSubAgent]);
|
||||
|
||||
function handleTabClick(tab: ToolTab) {
|
||||
setActiveTab(activeTab === tab ? null : tab);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`rounded-sm border bg-background-bright/40 ${
|
||||
hasSubAgent ? "border-indigo-500/30" : "border-grid-bright"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-2 px-2.5 py-1.5">
|
||||
{hasSubAgent && (
|
||||
<svg
|
||||
className="size-3.5 text-indigo-400"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={1.5}
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M8.25 3v1.5M4.5 8.25H3m18 0h-1.5M4.5 12H3m18 0h-1.5m-15 3.75H3m18 0h-1.5M8.25 19.5V21M12 3v1.5m0 15V21m3.75-18v1.5m0 15V21m-9-1.5h10.5a2.25 2.25 0 0 0 2.25-2.25V6.75a2.25 2.25 0 0 0-2.25-2.25H6.75A2.25 2.25 0 0 0 4.5 6.75v10.5a2.25 2.25 0 0 0 2.25 2.25Zm.75-12h9v9h-9v-9Z"
|
||||
/>
|
||||
</svg>
|
||||
)}
|
||||
<code
|
||||
className={`font-mono text-xs ${hasSubAgent ? "text-indigo-300" : "text-text-bright"}`}
|
||||
>
|
||||
{tool.toolName}
|
||||
</code>
|
||||
{hasSubAgent && tool.subAgent?.isStreaming && (
|
||||
<span className="flex items-center gap-1 text-[10px] text-indigo-400">
|
||||
<span className="inline-block size-1.5 animate-pulse rounded-full bg-indigo-400" />
|
||||
streaming
|
||||
</span>
|
||||
)}
|
||||
{tool.resultSummary && (
|
||||
<span className="ml-auto text-[10px] text-text-dimmed">{tool.resultSummary}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{availableTabs.length > 0 && (
|
||||
<>
|
||||
<div
|
||||
className={`flex gap-0 border-t ${
|
||||
hasSubAgent ? "border-indigo-500/20" : "border-grid-bright"
|
||||
}`}
|
||||
>
|
||||
{availableTabs.map((tab) => (
|
||||
<button
|
||||
key={tab}
|
||||
onClick={() => handleTabClick(tab)}
|
||||
className={`px-2.5 py-1 text-[11px] capitalize transition-colors ${
|
||||
activeTab === tab
|
||||
? "bg-background-hover text-text-bright"
|
||||
: "text-text-dimmed hover:text-text-bright"
|
||||
}`}
|
||||
>
|
||||
{tab}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{activeTab === "agent" && hasSubAgent && <SubAgentContent parts={tool.subAgent!.parts} />}
|
||||
|
||||
{activeTab === "input" && hasInput && (
|
||||
<div className="border-t border-grid-dimmed">
|
||||
<CodeBlock
|
||||
code={tool.inputJson}
|
||||
maxLines={12}
|
||||
showLineNumbers={false}
|
||||
showCopyButton
|
||||
className="rounded-none border-0"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === "output" && hasResult && (
|
||||
<div className="border-t border-grid-dimmed">
|
||||
{isJsonString(tool.resultOutput!) ? (
|
||||
<CodeBlock
|
||||
code={tool.resultOutput!}
|
||||
maxLines={16}
|
||||
showLineNumbers={false}
|
||||
showCopyButton
|
||||
className="rounded-none border-0"
|
||||
/>
|
||||
) : (
|
||||
<div className="streamdown-container p-2.5 font-sans text-sm font-normal text-text-dimmed">
|
||||
<Suspense
|
||||
fallback={<span className="whitespace-pre-wrap">{tool.resultOutput}</span>}
|
||||
>
|
||||
<StreamdownRenderer>{tool.resultOutput!}</StreamdownRenderer>
|
||||
</Suspense>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === "details" && hasDetails && (
|
||||
<div className="flex flex-col gap-2 border-t border-grid-dimmed px-2.5 py-2">
|
||||
{tool.description && (
|
||||
<p className="text-xs leading-relaxed text-text-dimmed">{tool.description}</p>
|
||||
)}
|
||||
{tool.parametersJson && (
|
||||
<div>
|
||||
<span className="text-[10px] font-medium uppercase tracking-wide text-text-dimmed">
|
||||
Parameters schema
|
||||
</span>
|
||||
<CodeBlock
|
||||
code={tool.parametersJson}
|
||||
maxLines={16}
|
||||
showLineNumbers={false}
|
||||
showCopyButton
|
||||
className="rounded-none border-0"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SubAgentContent({ parts }: { parts: any[] }) {
|
||||
// Extract sub-agent run ID from injected metadata part
|
||||
const runPart = parts.find((p: any) => p.type === "data-subagent-run" && p.data?.runId);
|
||||
const subAgentRunId = runPart?.data?.runId as string | undefined;
|
||||
|
||||
return (
|
||||
<div className="space-y-2 border-t border-indigo-500/20 p-2.5">
|
||||
{subAgentRunId && (
|
||||
<div className="flex justify-end">
|
||||
<LinkButton to={`/runs/${subAgentRunId}`} variant="tertiary/small" target="_blank">
|
||||
View sub-agent run
|
||||
</LinkButton>
|
||||
</div>
|
||||
)}
|
||||
{parts.map((part: any, j: number) => {
|
||||
const partType = part.type as string;
|
||||
|
||||
// Skip the injected metadata part — already rendered above
|
||||
if (partType === "data-subagent-run") return null;
|
||||
|
||||
if (partType === "text" && part.text) {
|
||||
return <AssistantResponse key={j} text={part.text} headerLabel="" />;
|
||||
}
|
||||
|
||||
if (partType === "step-start") {
|
||||
return (
|
||||
<div key={j} className="flex items-center gap-2 py-0.5">
|
||||
<div className="flex-1 border-t border-dashed border-border-bright" />
|
||||
<span className="text-[10px] text-text-faint">step</span>
|
||||
<div className="flex-1 border-t border-dashed border-border-bright" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (partType.startsWith("tool-")) {
|
||||
const subToolName = partType.slice(5);
|
||||
return (
|
||||
<ToolUseRow
|
||||
key={j}
|
||||
tool={{
|
||||
toolCallId: part.toolCallId ?? `sub-tool-${j}`,
|
||||
toolName: subToolName,
|
||||
inputJson: JSON.stringify(part.input ?? {}, null, 2),
|
||||
resultOutput:
|
||||
part.output != null
|
||||
? typeof part.output === "string"
|
||||
? part.output
|
||||
: JSON.stringify(part.output, null, 2)
|
||||
: undefined,
|
||||
resultSummary:
|
||||
part.state === "input-streaming" || part.state === "input-available"
|
||||
? "calling..."
|
||||
: part.state === "output-error"
|
||||
? `error: ${part.errorText ?? "unknown"}`
|
||||
: undefined,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (partType === "reasoning" && part.text) {
|
||||
return (
|
||||
<div key={j} className="border-l-2 border-amber-500/40 pl-2">
|
||||
<div className="whitespace-pre-wrap text-xs italic text-amber-200/70">
|
||||
{part.text}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import { Header3 } from "~/components/primitives/Headers";
|
||||
import { Paragraph } from "~/components/primitives/Paragraph";
|
||||
import { formatDuration } from "./aiHelpers";
|
||||
import { SpanMetricRow as MetricRow } from "./SpanMetricRow";
|
||||
|
||||
export type AIEmbedData = {
|
||||
model: string;
|
||||
provider: string;
|
||||
value?: string;
|
||||
durationMs: number;
|
||||
};
|
||||
|
||||
export function extractAIEmbedData(
|
||||
properties: Record<string, unknown>,
|
||||
durationMs: number
|
||||
): AIEmbedData | undefined {
|
||||
const ai = properties.ai;
|
||||
if (!ai || typeof ai !== "object") return undefined;
|
||||
|
||||
const a = ai as Record<string, unknown>;
|
||||
if (a.operationId !== "ai.embed") return undefined;
|
||||
|
||||
const aiModel = a.model;
|
||||
if (!aiModel || typeof aiModel !== "object") return undefined;
|
||||
|
||||
const m = aiModel as Record<string, unknown>;
|
||||
const model = typeof m.id === "string" ? m.id : undefined;
|
||||
if (!model) return undefined;
|
||||
|
||||
return {
|
||||
model,
|
||||
provider: typeof m.provider === "string" ? m.provider : "unknown",
|
||||
value: typeof a.value === "string" ? a.value : undefined,
|
||||
durationMs,
|
||||
};
|
||||
}
|
||||
|
||||
export function AIEmbedSpanDetails({ data }: { data: AIEmbedData }) {
|
||||
return (
|
||||
<div className="flex h-full flex-col overflow-hidden">
|
||||
<div className="flex-1 overflow-y-auto scrollbar-thin scrollbar-track-transparent scrollbar-thumb-surface-control">
|
||||
<div className="flex flex-col px-3">
|
||||
{/* Model info */}
|
||||
<div className="flex flex-col gap-1 py-2.5">
|
||||
<div className="flex flex-col text-xs @container">
|
||||
<MetricRow label="Model" value={data.model} />
|
||||
<MetricRow label="Provider" value={data.provider} />
|
||||
<MetricRow label="Duration" value={formatDuration(data.durationMs)} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Input value */}
|
||||
{data.value && (
|
||||
<div className="flex flex-col gap-1.5 py-2.5">
|
||||
<Header3>Input</Header3>
|
||||
<div className="rounded-md border border-grid-bright bg-background-hover/50 px-3.5 py-2">
|
||||
<Paragraph variant="small/dimmed">{data.value}</Paragraph>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { formatCurrencyAccurate } from "~/utils/numberFormatter";
|
||||
import { Header3 } from "~/components/primitives/Headers";
|
||||
import { TextLink } from "~/components/primitives/TextLink";
|
||||
import { useEnvironment } from "~/hooks/useEnvironment";
|
||||
import { useOrganization } from "~/hooks/useOrganizations";
|
||||
import { useProject } from "~/hooks/useProject";
|
||||
import { v3PromptPath } from "~/utils/pathBuilder";
|
||||
import { TruncatedCopyableValue } from "~/components/primitives/TruncatedCopyableValue";
|
||||
import type { AISpanData } from "./types";
|
||||
|
||||
export function AITagsRow({ aiData }: { aiData: AISpanData }) {
|
||||
const organization = useOrganization();
|
||||
const project = useProject();
|
||||
const environment = useEnvironment();
|
||||
|
||||
const promptLink =
|
||||
aiData.promptSlug && organization && project && environment
|
||||
? v3PromptPath(organization, project, environment, aiData.promptSlug, aiData.promptVersion)
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-1 py-2.5">
|
||||
<div className="flex flex-col text-xs @container">
|
||||
{aiData.responseId && (
|
||||
<MetricRow
|
||||
label="Response ID"
|
||||
value={<TruncatedCopyableValue value={aiData.responseId} />}
|
||||
/>
|
||||
)}
|
||||
<MetricRow label="Model" value={aiData.model} />
|
||||
{aiData.provider !== "unknown" && <MetricRow label="Provider" value={aiData.provider} />}
|
||||
{aiData.resolvedProvider && (
|
||||
<MetricRow label="Resolved provider" value={aiData.resolvedProvider} />
|
||||
)}
|
||||
{aiData.promptSlug && (
|
||||
<MetricRow
|
||||
label="Prompt"
|
||||
value={
|
||||
promptLink ? (
|
||||
<TextLink to={promptLink}>
|
||||
{aiData.promptSlug}
|
||||
{aiData.promptVersion ? ` v${aiData.promptVersion}` : ""}
|
||||
</TextLink>
|
||||
) : (
|
||||
`${aiData.promptSlug}${aiData.promptVersion ? ` v${aiData.promptVersion}` : ""}`
|
||||
)
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{aiData.finishReason && <MetricRow label="Finish reason" value={aiData.finishReason} />}
|
||||
{aiData.serviceTier && <MetricRow label="Service tier" value={aiData.serviceTier} />}
|
||||
{aiData.toolChoice && <MetricRow label="Tool choice" value={aiData.toolChoice} />}
|
||||
{aiData.toolCount != null && aiData.toolCount > 0 && (
|
||||
<MetricRow
|
||||
label="Tools provided"
|
||||
value={`${aiData.toolCount} ${aiData.toolCount === 1 ? "tool" : "tools"}`}
|
||||
/>
|
||||
)}
|
||||
{aiData.messageCount != null && (
|
||||
<MetricRow
|
||||
label="Messages"
|
||||
value={`${aiData.messageCount} ${aiData.messageCount === 1 ? "message" : "messages"}`}
|
||||
/>
|
||||
)}
|
||||
{aiData.telemetryMetadata &&
|
||||
Object.entries(aiData.telemetryMetadata)
|
||||
.filter(([key]) => key !== "prompt")
|
||||
.map(([key, value]) => <MetricRow key={key} label={key} value={value} />)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function AIStatsSummary({ aiData }: { aiData: AISpanData }) {
|
||||
return (
|
||||
<div className="flex flex-col gap-1 py-2.5">
|
||||
<Header3>Stats</Header3>
|
||||
<div className="flex flex-col text-xs @container">
|
||||
<MetricRow label="Input" value={aiData.inputTokens.toLocaleString()} unit="tokens" />
|
||||
<MetricRow label="Output" value={aiData.outputTokens.toLocaleString()} unit="tokens" />
|
||||
{aiData.cachedTokens != null && aiData.cachedTokens > 0 && (
|
||||
<MetricRow
|
||||
label="Cache read"
|
||||
value={aiData.cachedTokens.toLocaleString()}
|
||||
unit="tokens"
|
||||
/>
|
||||
)}
|
||||
{aiData.cacheCreationTokens != null && aiData.cacheCreationTokens > 0 && (
|
||||
<MetricRow
|
||||
label="Cache write"
|
||||
value={aiData.cacheCreationTokens.toLocaleString()}
|
||||
unit="tokens"
|
||||
/>
|
||||
)}
|
||||
{aiData.reasoningTokens != null && aiData.reasoningTokens > 0 && (
|
||||
<MetricRow
|
||||
label="Reasoning"
|
||||
value={aiData.reasoningTokens.toLocaleString()}
|
||||
unit="tokens"
|
||||
/>
|
||||
)}
|
||||
<MetricRow label="Total" value={aiData.totalTokens.toLocaleString()} unit="tokens" bold />
|
||||
|
||||
{aiData.totalCost != null && (
|
||||
<MetricRow label="Cost" value={formatCurrencyAccurate(aiData.totalCost)} />
|
||||
)}
|
||||
{aiData.msToFirstChunk != null && (
|
||||
<MetricRow label="TTFC" value={formatTtfc(aiData.msToFirstChunk)} />
|
||||
)}
|
||||
{aiData.tokensPerSecond != null && (
|
||||
<MetricRow label="Speed" value={`${Math.round(aiData.tokensPerSecond)} tok/s`} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MetricRow({
|
||||
label,
|
||||
value,
|
||||
unit,
|
||||
bold,
|
||||
}: {
|
||||
label: string;
|
||||
value: ReactNode;
|
||||
unit?: string;
|
||||
bold?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div className="grid h-7 grid-cols-[1fr_auto] items-center gap-4 rounded-sm px-1.5 transition odd:bg-background-hover/40 @[28rem]:grid-cols-[8rem_1fr] hover:bg-white/4">
|
||||
<span className="text-text-dimmed">{label}</span>
|
||||
<span
|
||||
className={`text-right @[28rem]:text-left ${
|
||||
bold ? "font-medium text-text-bright" : "text-text-bright"
|
||||
}`}
|
||||
>
|
||||
{value}
|
||||
{unit && <span className="ml-1 text-text-dimmed">{unit}</span>}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function formatTtfc(ms: number): string {
|
||||
if (ms >= 10_000) {
|
||||
return `${(ms / 1000).toFixed(1)}s`;
|
||||
}
|
||||
return `${Math.round(ms)}ms`;
|
||||
}
|
||||
@@ -0,0 +1,357 @@
|
||||
import { CheckIcon, ClipboardDocumentIcon } from "@heroicons/react/20/solid";
|
||||
import { Suspense, useState } from "react";
|
||||
import { Button } from "~/components/primitives/Buttons";
|
||||
import { StreamdownRenderer } from "~/components/code/StreamdownRenderer";
|
||||
import { Header3 } from "~/components/primitives/Headers";
|
||||
import { Paragraph } from "~/components/primitives/Paragraph";
|
||||
import { TabButton, TabContainer } from "~/components/primitives/Tabs";
|
||||
import { TextLink } from "~/components/primitives/TextLink";
|
||||
import { tryPrettyJson } from "./aiHelpers";
|
||||
import { SpanMetricRow as PromptMetricRow } from "./SpanMetricRow";
|
||||
import { useEnvironment } from "~/hooks/useEnvironment";
|
||||
import { useOrganization } from "~/hooks/useOrganizations";
|
||||
import { useProject } from "~/hooks/useProject";
|
||||
import { useHasAdminAccess } from "~/hooks/useUser";
|
||||
import { v3PromptPath } from "~/utils/pathBuilder";
|
||||
import { CodeBlock } from "~/components/code/CodeBlock";
|
||||
import { AIChatMessages, AssistantResponse, ChatBubble, type PromptLink } from "./AIChatMessages";
|
||||
import { AIStatsSummary, AITagsRow } from "./AIModelSummary";
|
||||
import { AIToolsInventory } from "./AIToolsInventory";
|
||||
import type { AISpanData, DisplayItem } from "./types";
|
||||
import type { PromptSpanData } from "~/presenters/v3/SpanPresenter.server";
|
||||
import { SpanHorizontalTimeline } from "~/components/runs/v3/SpanHorizontalTimeline";
|
||||
|
||||
type AITab = "overview" | "messages" | "tools" | "prompt";
|
||||
|
||||
export function AISpanDetails({
|
||||
aiData,
|
||||
promptVersionData,
|
||||
rawProperties,
|
||||
startTime,
|
||||
duration,
|
||||
}: {
|
||||
aiData: AISpanData;
|
||||
promptVersionData?: PromptSpanData;
|
||||
rawProperties?: string;
|
||||
startTime?: string | Date;
|
||||
duration?: number | null;
|
||||
}) {
|
||||
const [tab, setTab] = useState<AITab>("overview");
|
||||
const isAdmin = useHasAdminAccess();
|
||||
const toolCount = aiData.toolCount ?? aiData.toolDefinitions?.length ?? 0;
|
||||
|
||||
const organization = useOrganization();
|
||||
const project = useProject();
|
||||
const environment = useEnvironment();
|
||||
|
||||
const promptLink: PromptLink | undefined =
|
||||
aiData.promptSlug && organization && project && environment
|
||||
? {
|
||||
slug: aiData.promptSlug,
|
||||
version: aiData.promptVersion,
|
||||
path: v3PromptPath(
|
||||
organization,
|
||||
project,
|
||||
environment,
|
||||
aiData.promptSlug,
|
||||
aiData.promptVersion
|
||||
),
|
||||
}
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col overflow-hidden">
|
||||
{/* Tab bar */}
|
||||
<div className="shrink-0 overflow-x-auto px-3 py-1 scrollbar-thin scrollbar-track-transparent scrollbar-thumb-surface-control">
|
||||
<TabContainer>
|
||||
<TabButton
|
||||
isActive={tab === "overview"}
|
||||
layoutId="ai-span"
|
||||
onClick={() => setTab("overview")}
|
||||
shortcut={{ key: "o" }}
|
||||
>
|
||||
Overview
|
||||
</TabButton>
|
||||
<TabButton
|
||||
isActive={tab === "messages"}
|
||||
layoutId="ai-span"
|
||||
onClick={() => setTab("messages")}
|
||||
shortcut={{ key: "m" }}
|
||||
>
|
||||
Messages
|
||||
</TabButton>
|
||||
<TabButton
|
||||
isActive={tab === "tools"}
|
||||
layoutId="ai-span"
|
||||
onClick={() => setTab("tools")}
|
||||
shortcut={{ key: "t" }}
|
||||
>
|
||||
<span className="inline-flex items-center whitespace-nowrap">
|
||||
Tools
|
||||
{toolCount > 0 && (
|
||||
<span className="ml-1 inline-flex min-w-4 -translate-y-px items-center justify-center rounded-full border border-border-bright bg-secondary px-1 py-0.5 text-[0.625rem] font-medium leading-none text-text-bright">
|
||||
{toolCount}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</TabButton>
|
||||
{promptVersionData && (
|
||||
<TabButton
|
||||
isActive={tab === "prompt"}
|
||||
layoutId="ai-span"
|
||||
onClick={() => setTab("prompt")}
|
||||
shortcut={{ key: "p" }}
|
||||
>
|
||||
Prompt
|
||||
</TabButton>
|
||||
)}
|
||||
</TabContainer>
|
||||
</div>
|
||||
|
||||
{/* Tab content */}
|
||||
<div className="scrollbar-gutter-stable min-h-0 flex-1 overflow-y-auto scrollbar-thin scrollbar-track-transparent scrollbar-thumb-surface-control">
|
||||
{tab === "overview" && (
|
||||
<>
|
||||
{startTime && (
|
||||
<div className="px-3">
|
||||
<SpanHorizontalTimeline startTime={startTime} duration={duration ?? null} />
|
||||
</div>
|
||||
)}
|
||||
<OverviewTab aiData={aiData} />
|
||||
</>
|
||||
)}
|
||||
{tab === "messages" && <MessagesTab aiData={aiData} promptLink={promptLink} />}
|
||||
{tab === "tools" && <ToolsTab aiData={aiData} />}
|
||||
{tab === "prompt" && promptVersionData && (
|
||||
<PromptTab promptData={promptVersionData} promptLink={promptLink} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer: Copy raw (admin only) */}
|
||||
{isAdmin && rawProperties && <CopyRawFooter rawProperties={rawProperties} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function OverviewTab({ aiData }: { aiData: AISpanData }) {
|
||||
const { userText, outputText, outputObject, outputToolNames } = extractInputOutput(aiData);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col px-3">
|
||||
<AITagsRow aiData={aiData} />
|
||||
<AIStatsSummary aiData={aiData} />
|
||||
|
||||
{userText && (
|
||||
<div className="flex flex-col gap-1.5 py-2.5">
|
||||
<Header3>Input</Header3>
|
||||
<ChatBubble>
|
||||
<Paragraph variant="small/dimmed">{userText}</Paragraph>
|
||||
</ChatBubble>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{outputText && <AssistantResponse text={outputText} headerLabel="Output" />}
|
||||
{!outputText && outputObject && (
|
||||
<div className="flex flex-col gap-1.5 py-2.5">
|
||||
<Header3>Output</Header3>
|
||||
<CodeBlock
|
||||
code={outputObject}
|
||||
maxLines={20}
|
||||
showLineNumbers={false}
|
||||
showCopyButton
|
||||
language="json"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{outputToolNames.length > 0 && !outputText && !outputObject && (
|
||||
<div className="flex flex-col gap-1.5 py-2.5">
|
||||
<Header3>Output</Header3>
|
||||
<ChatBubble>
|
||||
<Paragraph variant="small/dimmed">
|
||||
Called {outputToolNames.length === 1 ? "tool" : "tools"}:{" "}
|
||||
<span className="font-mono text-text-bright">{outputToolNames.join(", ")}</span>
|
||||
</Paragraph>
|
||||
</ChatBubble>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MessagesTab({ aiData, promptLink }: { aiData: AISpanData; promptLink?: PromptLink }) {
|
||||
const showFallbackText = aiData.responseText && !hasAssistantItem(aiData.items);
|
||||
const showFallbackObject =
|
||||
!showFallbackText && aiData.responseObject && !hasAssistantItem(aiData.items);
|
||||
|
||||
return (
|
||||
<div className="px-3">
|
||||
<div className="flex flex-col">
|
||||
{aiData.items && aiData.items.length > 0 && (
|
||||
<AIChatMessages items={aiData.items} promptLink={promptLink} />
|
||||
)}
|
||||
{showFallbackText && <AssistantResponse text={aiData.responseText!} />}
|
||||
{showFallbackObject && (
|
||||
<div className="flex flex-col gap-1.5 py-2.5">
|
||||
<Header3>Assistant</Header3>
|
||||
<CodeBlock
|
||||
code={aiData.responseObject!}
|
||||
maxLines={20}
|
||||
showLineNumbers={false}
|
||||
showCopyButton
|
||||
language="json"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ToolsTab({ aiData }: { aiData: AISpanData }) {
|
||||
return <AIToolsInventory aiData={aiData} />;
|
||||
}
|
||||
|
||||
function CopyRawFooter({ rawProperties }: { rawProperties: string }) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
function handleCopy() {
|
||||
navigator.clipboard.writeText(rawProperties);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-13 shrink-0 items-center justify-end border-t border-grid-dimmed px-2">
|
||||
<Button
|
||||
variant="minimal/medium"
|
||||
onClick={handleCopy}
|
||||
LeadingIcon={copied ? CheckIcon : ClipboardDocumentIcon}
|
||||
leadingIconClassName={copied ? "text-green-500" : undefined}
|
||||
>
|
||||
Copy raw properties
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PromptTab({
|
||||
promptData,
|
||||
promptLink,
|
||||
}: {
|
||||
promptData: PromptSpanData;
|
||||
promptLink?: PromptLink;
|
||||
}) {
|
||||
const labels = promptData.labels
|
||||
? promptData.labels
|
||||
.split(",")
|
||||
.map((l) => l.trim())
|
||||
.filter(Boolean)
|
||||
: [];
|
||||
|
||||
return (
|
||||
<div className="flex flex-col px-3">
|
||||
{/* Prompt properties */}
|
||||
<div className="flex flex-col gap-1 py-2.5">
|
||||
<div className="flex flex-col text-xs @container">
|
||||
<PromptMetricRow
|
||||
label="Prompt"
|
||||
value={
|
||||
promptLink ? (
|
||||
<TextLink to={promptLink.path}>{promptData.slug}</TextLink>
|
||||
) : (
|
||||
promptData.slug
|
||||
)
|
||||
}
|
||||
/>
|
||||
<PromptMetricRow label="Version" value={`v${promptData.version}`} />
|
||||
{labels.length > 0 && <PromptMetricRow label="Labels" value={labels.join(", ")} />}
|
||||
{promptData.model && <PromptMetricRow label="Model" value={promptData.model} />}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Prompt input (variables passed to resolve()) */}
|
||||
{promptData.input && (
|
||||
<div className="flex flex-col gap-1.5 py-2.5">
|
||||
<Header3>Input</Header3>
|
||||
<CodeBlock
|
||||
code={tryPrettyJson(promptData.input)}
|
||||
maxLines={20}
|
||||
showLineNumbers={false}
|
||||
showCopyButton
|
||||
language="json"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Template (from the prompt version) */}
|
||||
{promptData.template && (
|
||||
<div className="flex flex-col gap-1.5 py-2.5">
|
||||
<Header3>Template</Header3>
|
||||
<div className="rounded-md border border-grid-bright bg-background-hover/50 px-3.5 py-2">
|
||||
<div className="font-sans text-sm font-normal text-text-dimmed streamdown-container">
|
||||
<Suspense
|
||||
fallback={<span className="whitespace-pre-wrap">{promptData.template}</span>}
|
||||
>
|
||||
<StreamdownRenderer>{promptData.template}</StreamdownRenderer>
|
||||
</Suspense>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function extractInputOutput(aiData: AISpanData): {
|
||||
userText: string | undefined;
|
||||
outputText: string | undefined;
|
||||
outputObject: string | undefined;
|
||||
outputToolNames: string[];
|
||||
} {
|
||||
let userText: string | undefined;
|
||||
let outputText: string | undefined;
|
||||
const outputToolNames: string[] = [];
|
||||
|
||||
if (aiData.items) {
|
||||
for (let i = aiData.items.length - 1; i >= 0; i--) {
|
||||
if (aiData.items[i].type === "user") {
|
||||
userText = (aiData.items[i] as { type: "user"; text: string }).text;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
for (let i = aiData.items.length - 1; i >= 0; i--) {
|
||||
const item = aiData.items[i];
|
||||
if (item.type === "assistant") {
|
||||
outputText = item.text;
|
||||
break;
|
||||
}
|
||||
if (item.type === "tool-use") {
|
||||
for (const tool of item.tools) {
|
||||
outputToolNames.push(tool.toolName);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!outputText && aiData.responseText) {
|
||||
outputText = aiData.responseText;
|
||||
}
|
||||
|
||||
return {
|
||||
userText,
|
||||
outputText,
|
||||
outputObject: aiData.responseObject ? tryPrettyJson(aiData.responseObject) : undefined,
|
||||
outputToolNames,
|
||||
};
|
||||
}
|
||||
|
||||
function hasAssistantItem(items: DisplayItem[] | undefined): boolean {
|
||||
if (!items) return false;
|
||||
return items.some((item) => item.type === "assistant");
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import { Header3 } from "~/components/primitives/Headers";
|
||||
import { CodeBlock } from "~/components/code/CodeBlock";
|
||||
import { TruncatedCopyableValue } from "~/components/primitives/TruncatedCopyableValue";
|
||||
import { formatDuration, tryPrettyJson } from "./aiHelpers";
|
||||
import { SpanMetricRow as MetricRow } from "./SpanMetricRow";
|
||||
|
||||
export type AIToolCallData = {
|
||||
toolName: string;
|
||||
toolCallId: string;
|
||||
args?: string;
|
||||
durationMs: number;
|
||||
};
|
||||
|
||||
export function extractAIToolCallData(
|
||||
properties: Record<string, unknown>,
|
||||
durationMs: number
|
||||
): AIToolCallData | undefined {
|
||||
const ai = properties.ai;
|
||||
if (!ai || typeof ai !== "object") return undefined;
|
||||
|
||||
const a = ai as Record<string, unknown>;
|
||||
if (a.operationId !== "ai.toolCall") return undefined;
|
||||
|
||||
const toolCall = a.toolCall;
|
||||
if (!toolCall || typeof toolCall !== "object") return undefined;
|
||||
|
||||
const tc = toolCall as Record<string, unknown>;
|
||||
const toolName = typeof tc.name === "string" ? tc.name : undefined;
|
||||
if (!toolName) return undefined;
|
||||
|
||||
return {
|
||||
toolName,
|
||||
toolCallId: typeof tc.id === "string" ? tc.id : "",
|
||||
args: typeof tc.args === "string" ? tc.args : undefined,
|
||||
durationMs,
|
||||
};
|
||||
}
|
||||
|
||||
export function AIToolCallSpanDetails({ data }: { data: AIToolCallData }) {
|
||||
return (
|
||||
<div className="flex h-full flex-col overflow-hidden">
|
||||
<div className="flex-1 overflow-y-auto scrollbar-thin scrollbar-track-transparent scrollbar-thumb-surface-control">
|
||||
<div className="flex flex-col px-3">
|
||||
{/* Tool info */}
|
||||
<div className="flex flex-col gap-1 py-2.5">
|
||||
<div className="flex flex-col text-xs @container">
|
||||
<MetricRow label="Tool" value={data.toolName} />
|
||||
{data.toolCallId && (
|
||||
<MetricRow
|
||||
label="Call ID"
|
||||
value={<TruncatedCopyableValue value={data.toolCallId} />}
|
||||
/>
|
||||
)}
|
||||
<MetricRow label="Duration" value={formatDuration(data.durationMs)} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Input args */}
|
||||
{data.args && (
|
||||
<div className="flex flex-col gap-1.5 py-2.5">
|
||||
<Header3>Input</Header3>
|
||||
<CodeBlock
|
||||
code={tryPrettyJson(data.args)}
|
||||
maxLines={20}
|
||||
showLineNumbers={false}
|
||||
showCopyButton
|
||||
language="json"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
import { useState } from "react";
|
||||
import { CodeBlock } from "~/components/code/CodeBlock";
|
||||
import type { AISpanData, ToolDefinition } from "./types";
|
||||
import { Paragraph } from "~/components/primitives/Paragraph";
|
||||
|
||||
export function AIToolsInventory({ aiData }: { aiData: AISpanData }) {
|
||||
const defs = aiData.toolDefinitions ?? [];
|
||||
const calledNames = getCalledToolNames(aiData);
|
||||
|
||||
if (defs.length === 0) {
|
||||
return (
|
||||
<div className="px-3 py-6 text-center">
|
||||
<Paragraph variant="small/dimmed">No tool definitions available for this span.</Paragraph>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col divide-y divide-grid-bright px-3">
|
||||
{defs.map((def) => {
|
||||
const wasCalled = calledNames.has(def.name);
|
||||
return <ToolDefRow key={def.name} def={def} wasCalled={wasCalled} />;
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ToolDefRow({ def, wasCalled }: { def: ToolDefinition; wasCalled: boolean }) {
|
||||
const [showSchema, setShowSchema] = useState(false);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-1.5 py-2.5">
|
||||
<div className="flex items-center gap-2">
|
||||
<div
|
||||
className={`size-1.5 shrink-0 rounded-full ${
|
||||
wasCalled ? "bg-success" : "bg-surface-control"
|
||||
}`}
|
||||
/>
|
||||
<code className="font-mono text-xs text-text-bright">{def.name}</code>
|
||||
<span className="text-[10px] text-text-dimmed">{wasCalled ? "called" : "not called"}</span>
|
||||
</div>
|
||||
|
||||
{def.description && (
|
||||
<p className="pl-3.5 text-xs leading-relaxed text-text-dimmed">{def.description}</p>
|
||||
)}
|
||||
|
||||
{def.parametersJson && (
|
||||
<div className="pl-3.5">
|
||||
<button
|
||||
onClick={() => setShowSchema(!showSchema)}
|
||||
className="text-[10px] text-text-link hover:underline"
|
||||
>
|
||||
{showSchema ? "Hide schema" : "Show schema"}
|
||||
</button>
|
||||
{showSchema && (
|
||||
<div className="mt-1">
|
||||
<CodeBlock
|
||||
code={def.parametersJson}
|
||||
maxLines={16}
|
||||
showLineNumbers={false}
|
||||
showCopyButton
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function getCalledToolNames(aiData: AISpanData): Set<string> {
|
||||
const names = new Set<string>();
|
||||
if (!aiData.items) return names;
|
||||
|
||||
for (const item of aiData.items) {
|
||||
if (item.type === "tool-use") {
|
||||
for (const tool of item.tools) {
|
||||
names.add(tool.toolName);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return names;
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
export function SpanMetricRow({ label, value }: { label: string; value: ReactNode }) {
|
||||
return (
|
||||
<div className="grid h-7 grid-cols-[1fr_auto] items-center gap-4 rounded-sm px-1.5 transition odd:bg-background-hover/40 @[28rem]:grid-cols-[8rem_1fr] hover:bg-white/4">
|
||||
<span className="text-text-dimmed">{label}</span>
|
||||
<span className="text-right text-text-bright @[28rem]:text-left">{value}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
// Shared primitive helpers for AI span data extraction
|
||||
|
||||
export function rec(v: unknown): Record<string, unknown> {
|
||||
return v && typeof v === "object" ? (v as Record<string, unknown>) : {};
|
||||
}
|
||||
|
||||
export function str(v: unknown): string | undefined {
|
||||
return typeof v === "string" ? v : undefined;
|
||||
}
|
||||
|
||||
export function num(v: unknown): number | undefined {
|
||||
return typeof v === "number" ? v : undefined;
|
||||
}
|
||||
|
||||
export function tryPrettyJson(value: string): string {
|
||||
try {
|
||||
return JSON.stringify(JSON.parse(value), null, 2);
|
||||
} catch {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
export function formatDuration(ms: number): string {
|
||||
if (ms < 1000) return `${Math.round(ms)}ms`;
|
||||
if (ms < 60_000) return `${(ms / 1000).toFixed(1)}s`;
|
||||
const mins = Math.floor(ms / 60_000);
|
||||
const secs = ((ms % 60_000) / 1000).toFixed(0);
|
||||
return `${mins}m ${secs}s`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse provider metadata from a JSON string.
|
||||
* Handles Anthropic, Azure, OpenAI, Gateway, and OpenRouter formats.
|
||||
*/
|
||||
export function parseProviderMetadata(raw: unknown):
|
||||
| {
|
||||
serviceTier?: string;
|
||||
resolvedProvider?: string;
|
||||
gatewayCost?: string;
|
||||
responseId?: string;
|
||||
}
|
||||
| undefined {
|
||||
if (typeof raw !== "string") return undefined;
|
||||
try {
|
||||
const parsed = JSON.parse(raw) as Record<string, unknown>;
|
||||
if (!parsed || typeof parsed !== "object") return undefined;
|
||||
|
||||
let serviceTier: string | undefined;
|
||||
let resolvedProvider: string | undefined;
|
||||
let gatewayCost: string | undefined;
|
||||
let responseId: string | undefined;
|
||||
|
||||
// Anthropic: { anthropic: { usage: { service_tier: "standard" } } }
|
||||
const anthropic = rec(parsed.anthropic);
|
||||
serviceTier = str(rec(anthropic.usage).service_tier);
|
||||
|
||||
// Azure/OpenAI: { azure: { serviceTier: "default" } } or { openai: { serviceTier: "..." } }
|
||||
const openai = rec(parsed.openai);
|
||||
if (!serviceTier) {
|
||||
serviceTier = str(rec(parsed.azure).serviceTier) ?? str(openai.serviceTier);
|
||||
}
|
||||
|
||||
// OpenAI response ID
|
||||
responseId = str(openai.responseId);
|
||||
|
||||
// Gateway: { gateway: { routing: { finalProvider, resolvedProvider }, cost } }
|
||||
const gateway = rec(parsed.gateway);
|
||||
const routing = rec(gateway.routing);
|
||||
resolvedProvider = str(routing.finalProvider) ?? str(routing.resolvedProvider);
|
||||
gatewayCost = str(gateway.cost);
|
||||
|
||||
// OpenRouter: { openrouter: { provider: "xAI" } }
|
||||
if (!resolvedProvider) {
|
||||
resolvedProvider = str(rec(parsed.openrouter).provider);
|
||||
}
|
||||
|
||||
if (!serviceTier && !resolvedProvider && !gatewayCost && !responseId) return undefined;
|
||||
return { serviceTier, resolvedProvider, gatewayCost, responseId };
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract user-defined telemetry metadata, coercing non-string values.
|
||||
* Skips the "prompt" key which is handled separately.
|
||||
*/
|
||||
export function extractTelemetryMetadata(raw: unknown): Record<string, string> | undefined {
|
||||
if (!raw || typeof raw !== "object") return undefined;
|
||||
|
||||
const result: Record<string, string> = {};
|
||||
for (const [key, value] of Object.entries(raw as Record<string, unknown>)) {
|
||||
if (key === "prompt") continue;
|
||||
if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") {
|
||||
result[key] = String(value);
|
||||
}
|
||||
}
|
||||
|
||||
return Object.keys(result).length > 0 ? result : undefined;
|
||||
}
|
||||
@@ -0,0 +1,682 @@
|
||||
import { rec, str, num, parseProviderMetadata, extractTelemetryMetadata } from "./aiHelpers";
|
||||
import type { AISpanData, DisplayItem, ToolDefinition, ToolUse } from "./types";
|
||||
|
||||
/**
|
||||
* Extracts structured AI span data from unflattened OTEL span properties.
|
||||
*
|
||||
* Works with the nested object produced by `unflattenAttributes()` — expects
|
||||
* keys like `gen_ai.response.model`, `ai.prompt.messages`, `trigger.llm.total_cost`, etc.
|
||||
*
|
||||
* @param properties Unflattened span properties object
|
||||
* @param durationMs Span duration in milliseconds
|
||||
* @returns Structured AI data, or undefined if this isn't an AI generation span
|
||||
*/
|
||||
export function extractAISpanData(
|
||||
properties: Record<string, unknown>,
|
||||
durationMs: number
|
||||
): AISpanData | undefined {
|
||||
const genAi = properties.gen_ai;
|
||||
if (!genAi || typeof genAi !== "object") return undefined;
|
||||
|
||||
const g = genAi as Record<string, unknown>;
|
||||
const ai = rec(properties.ai);
|
||||
const trigger = rec(properties.trigger);
|
||||
|
||||
const gResponse = rec(g.response);
|
||||
const gRequest = rec(g.request);
|
||||
const gUsage = rec(g.usage);
|
||||
const gOperation = rec(g.operation);
|
||||
const gProvider = rec(g.provider);
|
||||
const gInput = rec(g.input);
|
||||
const gOutput = rec(g.output);
|
||||
const gTool = rec(g.tool);
|
||||
const aiModel = rec(ai.model);
|
||||
const aiResponse = rec(ai.response);
|
||||
const aiPrompt = rec(ai.prompt);
|
||||
const aiUsage = rec(ai.usage);
|
||||
const triggerLlm = rec(trigger.llm);
|
||||
|
||||
const model = str(gResponse.model) ?? str(gRequest.model) ?? str(aiModel.id);
|
||||
if (!model) return undefined;
|
||||
|
||||
// Prefer ai.usage (richer) over gen_ai.usage.
|
||||
// Gateway/some providers emit promptTokens/completionTokens instead of inputTokens/outputTokens.
|
||||
const inputTokens =
|
||||
num(aiUsage.inputTokens) ?? num(aiUsage.promptTokens) ?? num(gUsage.input_tokens) ?? 0;
|
||||
const outputTokens =
|
||||
num(aiUsage.outputTokens) ?? num(aiUsage.completionTokens) ?? num(gUsage.output_tokens) ?? 0;
|
||||
const totalTokens = num(aiUsage.totalTokens) ?? inputTokens + outputTokens;
|
||||
|
||||
const tokensPerSecond =
|
||||
num(aiResponse.avgOutputTokensPerSecond) ??
|
||||
(outputTokens > 0 && durationMs > 0
|
||||
? Math.round((outputTokens / (durationMs / 1000)) * 10) / 10
|
||||
: undefined);
|
||||
|
||||
// AI SDK 7 moves span emission into `@ai-sdk/otel`, which emits OTel GenAI
|
||||
// semantic-convention attributes (gen_ai.input/output.messages, gen_ai.provider.name,
|
||||
// gen_ai.tool.definitions, gen_ai.response.finish_reasons). AI SDK 6 emits the older
|
||||
// `ai.*` keys (ai.prompt.messages, ai.response.text/toolCalls/finishReason). Customers
|
||||
// run either major, so detect the shape and read both.
|
||||
const isV7 =
|
||||
typeof gInput.messages === "string" ||
|
||||
typeof gOutput.messages === "string" ||
|
||||
typeof gProvider.name === "string";
|
||||
|
||||
const toolDefs = parseToolDefinitions(isV7 ? gTool.definitions : aiPrompt.tools);
|
||||
const providerMeta = parseProviderMetadata(aiResponse.providerMetadata);
|
||||
const aiTelemetry = rec(ai.telemetry);
|
||||
const telemetryMetaRaw = rec(aiTelemetry.metadata);
|
||||
const promptMeta = rec(telemetryMetaRaw.prompt);
|
||||
const promptSlug = str(promptMeta.slug);
|
||||
const promptVersion = str(promptMeta.version);
|
||||
const promptModel = str(promptMeta.model);
|
||||
const promptLabels = str(promptMeta.labels);
|
||||
const promptInput = str(promptMeta.input);
|
||||
const telemetryMeta = extractTelemetryMetadata(aiTelemetry.metadata);
|
||||
|
||||
return {
|
||||
model,
|
||||
provider: str(gProvider.name) ?? str(g.system) ?? str(aiModel.provider) ?? "unknown",
|
||||
operationName: str(gOperation.name) ?? str(ai.operationId) ?? "",
|
||||
responseId: str(gResponse.id) || undefined,
|
||||
finishReason: isV7 ? firstFinishReason(gResponse.finish_reasons) : str(aiResponse.finishReason),
|
||||
serviceTier: providerMeta?.serviceTier,
|
||||
resolvedProvider: providerMeta?.resolvedProvider,
|
||||
toolChoice: parseToolChoice(aiPrompt.toolChoice),
|
||||
toolCount: toolDefs?.length,
|
||||
messageCount: isV7 ? countGenAiMessages(gInput.messages) : countMessages(aiPrompt.messages),
|
||||
telemetryMetadata: telemetryMeta,
|
||||
promptSlug: promptSlug || undefined,
|
||||
promptVersion: promptVersion || undefined,
|
||||
promptModel: promptModel || undefined,
|
||||
promptLabels: promptLabels || undefined,
|
||||
promptInput: promptInput || undefined,
|
||||
inputTokens,
|
||||
outputTokens,
|
||||
totalTokens,
|
||||
cachedTokens:
|
||||
num(aiUsage.cachedInputTokens) ??
|
||||
num(gUsage.cache_read_input_tokens) ??
|
||||
num(rec(gUsage.cache_read).input_tokens),
|
||||
cacheCreationTokens:
|
||||
num(aiUsage.cacheCreationInputTokens) ??
|
||||
num(gUsage.cache_creation_input_tokens) ??
|
||||
num(rec(gUsage.cache_creation).input_tokens),
|
||||
reasoningTokens: num(aiUsage.reasoningTokens) ?? num(gUsage.reasoning_tokens),
|
||||
tokensPerSecond,
|
||||
msToFirstChunk: num(aiResponse.msToFirstChunk),
|
||||
durationMs,
|
||||
inputCost: num(triggerLlm.input_cost),
|
||||
outputCost: num(triggerLlm.output_cost),
|
||||
totalCost: num(triggerLlm.total_cost),
|
||||
cachedCost: num(triggerLlm.cached_cost),
|
||||
cacheCreationCost: num(triggerLlm.cache_creation_cost),
|
||||
responseText: isV7
|
||||
? extractGenAiAssistantText(gOutput.messages) || undefined
|
||||
: str(aiResponse.text) || undefined,
|
||||
responseObject: str(aiResponse.object) || undefined,
|
||||
toolDefinitions: toolDefs,
|
||||
items: isV7
|
||||
? buildGenAiDisplayItems(g.system_instructions, gInput.messages, gOutput.messages, toolDefs)
|
||||
: buildDisplayItems(aiPrompt.messages, aiResponse.toolCalls, toolDefs),
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Message → DisplayItem transformation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type RawMessage = {
|
||||
role: string;
|
||||
content: unknown;
|
||||
toolCallId?: string;
|
||||
name?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Build display items from prompt messages and optionally response tool calls.
|
||||
* - Parses ai.prompt.messages and merges consecutive tool-call + tool-result pairs
|
||||
* - If ai.response.toolCalls is present (finishReason=tool-calls), appends those too
|
||||
*/
|
||||
function buildDisplayItems(
|
||||
messagesRaw: unknown,
|
||||
responseToolCallsRaw: unknown,
|
||||
toolDefs?: ToolDefinition[]
|
||||
): DisplayItem[] | undefined {
|
||||
const items = parseMessagesToDisplayItems(messagesRaw);
|
||||
const responseToolCalls = parseResponseToolCalls(responseToolCallsRaw);
|
||||
|
||||
if (!items && !responseToolCalls) return undefined;
|
||||
|
||||
const result = items ?? [];
|
||||
|
||||
if (responseToolCalls && responseToolCalls.length > 0) {
|
||||
result.push({ type: "tool-use", tools: responseToolCalls });
|
||||
}
|
||||
|
||||
if (toolDefs && toolDefs.length > 0) {
|
||||
const defsByName = new Map(toolDefs.map((d) => [d.name, d]));
|
||||
for (const item of result) {
|
||||
if (item.type === "tool-use") {
|
||||
for (const tool of item.tools) {
|
||||
const def = defsByName.get(tool.toolName);
|
||||
if (def) {
|
||||
tool.description = def.description;
|
||||
tool.parametersJson = def.parametersJson;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result.length > 0 ? result : undefined;
|
||||
}
|
||||
|
||||
function parseMessagesToDisplayItems(raw: unknown): DisplayItem[] | undefined {
|
||||
if (typeof raw !== "string") return undefined;
|
||||
|
||||
let messages: RawMessage[];
|
||||
try {
|
||||
const parsed = JSON.parse(raw);
|
||||
if (!Array.isArray(parsed)) return undefined;
|
||||
messages = parsed.map((item: unknown) => {
|
||||
const m = rec(item);
|
||||
return {
|
||||
role: str(m.role) ?? "user",
|
||||
content: m.content,
|
||||
toolCallId: str(m.toolCallId),
|
||||
name: str(m.name),
|
||||
};
|
||||
});
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const items: DisplayItem[] = [];
|
||||
let i = 0;
|
||||
|
||||
while (i < messages.length) {
|
||||
const msg = messages[i];
|
||||
|
||||
if (msg.role === "system") {
|
||||
items.push({ type: "system", text: extractTextContent(msg.content) });
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (msg.role === "user") {
|
||||
items.push({ type: "user", text: extractTextContent(msg.content) });
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Assistant message — check if it contains tool calls
|
||||
if (msg.role === "assistant") {
|
||||
const toolCalls = extractToolCalls(msg.content);
|
||||
|
||||
if (toolCalls.length > 0) {
|
||||
// Collect subsequent tool result messages that match these tool calls
|
||||
const _toolCallIds = new Set(toolCalls.map((tc) => tc.toolCallId));
|
||||
let j = i + 1;
|
||||
while (j < messages.length && messages[j].role === "tool") {
|
||||
j++;
|
||||
}
|
||||
// Gather tool result messages between i+1 and j
|
||||
const toolResultMsgs = messages.slice(i + 1, j);
|
||||
|
||||
// Build ToolUse entries by pairing calls with results
|
||||
const tools: ToolUse[] = toolCalls.map((tc) => {
|
||||
const resultMsg = toolResultMsgs.find((m) => {
|
||||
// Match by toolCallId in the message's content parts
|
||||
const results = extractToolResults(m.content);
|
||||
return results.some((r) => r.toolCallId === tc.toolCallId);
|
||||
});
|
||||
|
||||
const result = resultMsg
|
||||
? extractToolResults(resultMsg.content).find((r) => r.toolCallId === tc.toolCallId)
|
||||
: undefined;
|
||||
|
||||
return {
|
||||
toolCallId: tc.toolCallId,
|
||||
toolName: tc.toolName,
|
||||
inputJson: JSON.stringify(tc.input, null, 2),
|
||||
resultSummary: result?.summary,
|
||||
resultOutput: result?.formattedOutput,
|
||||
};
|
||||
});
|
||||
|
||||
items.push({ type: "tool-use", tools });
|
||||
i = j; // skip past the tool result messages
|
||||
continue;
|
||||
}
|
||||
|
||||
// Assistant message with just text
|
||||
const text = extractTextContent(msg.content);
|
||||
if (text) {
|
||||
items.push({ type: "assistant", text });
|
||||
}
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Skip any other message types (tool messages that weren't consumed above)
|
||||
i++;
|
||||
}
|
||||
|
||||
return items.length > 0 ? items : undefined;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Response tool calls (from ai.response.toolCalls, used when finishReason=tool-calls)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Parse ai.response.toolCalls JSON string into ToolUse entries.
|
||||
* These are tool calls the model requested but haven't been executed yet in this span.
|
||||
*/
|
||||
function parseResponseToolCalls(raw: unknown): ToolUse[] | undefined {
|
||||
if (typeof raw !== "string") return undefined;
|
||||
try {
|
||||
const parsed = JSON.parse(raw);
|
||||
if (!Array.isArray(parsed)) return undefined;
|
||||
const tools: ToolUse[] = [];
|
||||
for (const item of parsed) {
|
||||
const tc = rec(item);
|
||||
if (tc.type === "tool-call" || tc.toolName || tc.toolCallId) {
|
||||
tools.push({
|
||||
toolCallId: str(tc.toolCallId) ?? "",
|
||||
toolName: str(tc.toolName) ?? "",
|
||||
inputJson: JSON.stringify(
|
||||
tc.input && typeof tc.input === "object" ? tc.input : {},
|
||||
null,
|
||||
2
|
||||
),
|
||||
});
|
||||
}
|
||||
}
|
||||
return tools.length > 0 ? tools : undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Content part extraction
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function extractTextContent(content: unknown): string {
|
||||
if (typeof content === "string") return content;
|
||||
if (!Array.isArray(content)) return "";
|
||||
|
||||
const texts: string[] = [];
|
||||
for (const raw of content) {
|
||||
const p = rec(raw);
|
||||
if (p.type === "text" && typeof p.text === "string") {
|
||||
texts.push(p.text);
|
||||
} else if (typeof p.text === "string") {
|
||||
texts.push(p.text);
|
||||
}
|
||||
}
|
||||
return texts.join("\n");
|
||||
}
|
||||
|
||||
type ParsedToolCall = {
|
||||
toolCallId: string;
|
||||
toolName: string;
|
||||
input: Record<string, unknown>;
|
||||
};
|
||||
|
||||
function extractToolCalls(content: unknown): ParsedToolCall[] {
|
||||
if (!Array.isArray(content)) return [];
|
||||
const calls: ParsedToolCall[] = [];
|
||||
for (const raw of content) {
|
||||
const p = rec(raw);
|
||||
if (p.type === "tool-call") {
|
||||
calls.push({
|
||||
toolCallId: str(p.toolCallId) ?? "",
|
||||
toolName: str(p.toolName) ?? "",
|
||||
input: p.input && typeof p.input === "object" ? (p.input as Record<string, unknown>) : {},
|
||||
});
|
||||
}
|
||||
}
|
||||
return calls;
|
||||
}
|
||||
|
||||
type ParsedToolResult = {
|
||||
toolCallId: string;
|
||||
toolName: string;
|
||||
summary: string;
|
||||
formattedOutput: string;
|
||||
};
|
||||
|
||||
function extractToolResults(content: unknown): ParsedToolResult[] {
|
||||
if (!Array.isArray(content)) return [];
|
||||
const results: ParsedToolResult[] = [];
|
||||
for (const raw of content) {
|
||||
const p = rec(raw);
|
||||
if (p.type === "tool-result") {
|
||||
const { summary, formattedOutput } = summarizeToolOutput(p.output);
|
||||
results.push({
|
||||
toolCallId: str(p.toolCallId) ?? "",
|
||||
toolName: str(p.toolName) ?? "",
|
||||
summary,
|
||||
formattedOutput,
|
||||
});
|
||||
}
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Summarize a tool output into a short label and a formatted string for display.
|
||||
* Handles the AI SDK's `{ type: "json", value: { status, contentType, body, truncated } }` shape.
|
||||
*/
|
||||
function summarizeToolOutput(output: unknown): { summary: string; formattedOutput: string } {
|
||||
if (typeof output === "string") {
|
||||
return {
|
||||
summary: output.length > 80 ? output.slice(0, 80) + "..." : output,
|
||||
formattedOutput: output,
|
||||
};
|
||||
}
|
||||
|
||||
if (!output || typeof output !== "object") {
|
||||
return { summary: "result", formattedOutput: JSON.stringify(output, null, 2) };
|
||||
}
|
||||
|
||||
const o = output as Record<string, unknown>;
|
||||
|
||||
// AI SDK wraps tool results as { type: "json", value: { status, contentType, body, ... } }
|
||||
if (o.type === "json" && o.value && typeof o.value === "object") {
|
||||
const v = o.value as Record<string, unknown>;
|
||||
const parts: string[] = [];
|
||||
if (typeof v.status === "number") parts.push(`${v.status}`);
|
||||
if (typeof v.contentType === "string") parts.push(v.contentType);
|
||||
if (v.truncated === true) parts.push("truncated");
|
||||
return {
|
||||
summary: parts.length > 0 ? parts.join(" · ") : "json result",
|
||||
formattedOutput: JSON.stringify(v, null, 2),
|
||||
};
|
||||
}
|
||||
|
||||
return { summary: "result", formattedOutput: JSON.stringify(output, null, 2) };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tool definitions (from ai.prompt.tools)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Parse ai.prompt.tools — after the array fix, this arrives as a JSON array string
|
||||
* where each element is itself a JSON string of a tool definition.
|
||||
*/
|
||||
function parseToolDefinitions(raw: unknown): ToolDefinition[] | undefined {
|
||||
if (typeof raw !== "string") return undefined;
|
||||
try {
|
||||
const parsed = JSON.parse(raw);
|
||||
if (!Array.isArray(parsed)) return undefined;
|
||||
const defs: ToolDefinition[] = [];
|
||||
for (const item of parsed) {
|
||||
// Each item is either a JSON string or already an object
|
||||
const obj = typeof item === "string" ? JSON.parse(item) : item;
|
||||
if (!obj || typeof obj !== "object") continue;
|
||||
const o = obj as Record<string, unknown>;
|
||||
const name = str(o.name);
|
||||
if (!name) continue;
|
||||
const schema = o.parameters ?? o.inputSchema;
|
||||
defs.push({
|
||||
name,
|
||||
description: str(o.description),
|
||||
parametersJson:
|
||||
schema && typeof schema === "object" ? JSON.stringify(schema, null, 2) : undefined,
|
||||
});
|
||||
}
|
||||
return defs.length > 0 ? defs : undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tool choice parsing
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function parseToolChoice(raw: unknown): string | undefined {
|
||||
if (typeof raw !== "string") return undefined;
|
||||
try {
|
||||
const parsed = JSON.parse(raw);
|
||||
if (typeof parsed === "string") return parsed;
|
||||
if (parsed && typeof parsed === "object") {
|
||||
const obj = parsed as Record<string, unknown>;
|
||||
if (typeof obj.type === "string") return obj.type;
|
||||
}
|
||||
return undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Message count
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function countMessages(raw: unknown): number | undefined {
|
||||
if (typeof raw !== "string") return undefined;
|
||||
try {
|
||||
const parsed = JSON.parse(raw);
|
||||
if (!Array.isArray(parsed)) return undefined;
|
||||
return parsed.length > 0 ? parsed.length : undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// AI SDK 7 — @ai-sdk/otel GenAI semantic-convention shape
|
||||
// ---------------------------------------------------------------------------
|
||||
//
|
||||
// v7 moved span emission out of `ai` core into `@ai-sdk/otel`, which emits OTel
|
||||
// GenAI semantic-convention attributes instead of the v6 `ai.*` keys:
|
||||
// gen_ai.input.messages JSON string: [{ role, parts: [...] }]
|
||||
// gen_ai.output.messages JSON string: [{ role:"assistant", parts, finish_reason }]
|
||||
// gen_ai.system_instructions system prompt (plain string, or [{ type:"text", content }])
|
||||
// Message parts (per @ai-sdk/otel's convertMessagePartToSemConv):
|
||||
// { type:"text" | "reasoning", content }
|
||||
// { type:"tool_call", id, name, arguments }
|
||||
// { type:"tool_call_response", id, response } // response already unwrapped from the AI SDK envelope
|
||||
// Media / approval / custom parts are not surfaced in the display yet.
|
||||
|
||||
type GenAiMessage = { role: string; parts: Record<string, unknown>[]; finishReason?: string };
|
||||
|
||||
function parseGenAiMessages(raw: unknown): GenAiMessage[] | undefined {
|
||||
if (typeof raw !== "string") return undefined;
|
||||
try {
|
||||
const parsed = JSON.parse(raw);
|
||||
if (!Array.isArray(parsed)) return undefined;
|
||||
return parsed.map((m) => {
|
||||
const o = rec(m);
|
||||
return {
|
||||
role: str(o.role) ?? "user",
|
||||
parts: Array.isArray(o.parts) ? o.parts.map(rec) : [],
|
||||
finishReason: str(o.finish_reason),
|
||||
};
|
||||
});
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/** `gen_ai.response.finish_reasons` arrives as a JSON array string (e.g. `["stop"]`); take the first. */
|
||||
function firstFinishReason(raw: unknown): string | undefined {
|
||||
if (typeof raw !== "string") return undefined;
|
||||
try {
|
||||
const parsed = JSON.parse(raw);
|
||||
if (Array.isArray(parsed)) {
|
||||
const first = parsed.find((r) => typeof r === "string");
|
||||
return typeof first === "string" ? first : undefined;
|
||||
}
|
||||
if (typeof parsed === "string") return parsed || undefined;
|
||||
} catch {
|
||||
return raw || undefined;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function countGenAiMessages(raw: unknown): number | undefined {
|
||||
const msgs = parseGenAiMessages(raw);
|
||||
return msgs && msgs.length > 0 ? msgs.length : undefined;
|
||||
}
|
||||
|
||||
/** Concatenated text of all `text` parts across the assistant output messages. */
|
||||
function extractGenAiAssistantText(outputRaw: unknown): string {
|
||||
const msgs = parseGenAiMessages(outputRaw);
|
||||
if (!msgs) return "";
|
||||
const texts: string[] = [];
|
||||
for (const m of msgs) {
|
||||
if (m.role !== "assistant") continue;
|
||||
for (const p of m.parts) {
|
||||
if (p.type === "text" && typeof p.content === "string") texts.push(p.content);
|
||||
}
|
||||
}
|
||||
return texts.join("\n");
|
||||
}
|
||||
|
||||
/** Plain text of a message's `text` parts (reasoning parts aren't surfaced yet). */
|
||||
function genAiMessageText(parts: Record<string, unknown>[]): string {
|
||||
const texts: string[] = [];
|
||||
for (const p of parts) {
|
||||
if (p.type === "text" && typeof p.content === "string") texts.push(p.content);
|
||||
}
|
||||
return texts.join("\n");
|
||||
}
|
||||
|
||||
/** Parse `gen_ai.system_instructions` (plain string, or a JSON array of `{ type:"text", content }`). */
|
||||
function parseSystemInstructions(raw: unknown): string | undefined {
|
||||
if (typeof raw !== "string") return undefined;
|
||||
const trimmed = raw.trim();
|
||||
if (trimmed.startsWith("[")) {
|
||||
try {
|
||||
const parsed = JSON.parse(trimmed);
|
||||
if (Array.isArray(parsed)) {
|
||||
const text = parsed
|
||||
.map((p) => str(rec(p).content))
|
||||
.filter((t): t is string => Boolean(t))
|
||||
.join("\n");
|
||||
return text || undefined;
|
||||
}
|
||||
} catch {
|
||||
// fall through to raw
|
||||
}
|
||||
}
|
||||
return raw || undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build display items from the v7 GenAI message attributes: the system prompt,
|
||||
* the input message history, and the assistant's output for this span. Assistant
|
||||
* tool_call parts are paired with tool_call_response parts from following tool messages.
|
||||
*/
|
||||
function buildGenAiDisplayItems(
|
||||
systemInstructionsRaw: unknown,
|
||||
inputMessagesRaw: unknown,
|
||||
outputMessagesRaw: unknown,
|
||||
toolDefs?: ToolDefinition[]
|
||||
): DisplayItem[] | undefined {
|
||||
const items: DisplayItem[] = [];
|
||||
|
||||
const systemText = parseSystemInstructions(systemInstructionsRaw);
|
||||
if (systemText) items.push({ type: "system", text: systemText });
|
||||
|
||||
const messages = [
|
||||
...(parseGenAiMessages(inputMessagesRaw) ?? []),
|
||||
...(parseGenAiMessages(outputMessagesRaw) ?? []),
|
||||
];
|
||||
appendGenAiMessages(items, messages);
|
||||
|
||||
if (toolDefs && toolDefs.length > 0) {
|
||||
const defsByName = new Map(toolDefs.map((d) => [d.name, d]));
|
||||
for (const item of items) {
|
||||
if (item.type === "tool-use") {
|
||||
for (const tool of item.tools) {
|
||||
const def = defsByName.get(tool.toolName);
|
||||
if (def) {
|
||||
tool.description = def.description;
|
||||
tool.parametersJson = def.parametersJson;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return items.length > 0 ? items : undefined;
|
||||
}
|
||||
|
||||
function appendGenAiMessages(items: DisplayItem[], messages: GenAiMessage[]): void {
|
||||
let i = 0;
|
||||
while (i < messages.length) {
|
||||
const msg = messages[i];
|
||||
|
||||
if (msg.role === "system") {
|
||||
const text = genAiMessageText(msg.parts);
|
||||
if (text) items.push({ type: "system", text });
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (msg.role === "user") {
|
||||
const text = genAiMessageText(msg.parts);
|
||||
if (text) items.push({ type: "user", text });
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (msg.role === "assistant") {
|
||||
const text = genAiMessageText(msg.parts);
|
||||
if (text) items.push({ type: "assistant", text });
|
||||
|
||||
const toolCalls = msg.parts.filter((p) => p.type === "tool_call");
|
||||
if (toolCalls.length > 0) {
|
||||
// Collect tool_call_response parts from the tool messages that follow.
|
||||
const responsesById = new Map<string, unknown>();
|
||||
let j = i + 1;
|
||||
while (j < messages.length && messages[j].role === "tool") {
|
||||
for (const p of messages[j].parts) {
|
||||
if (p.type === "tool_call_response") {
|
||||
const id = str(p.id);
|
||||
if (id) responsesById.set(id, p.response);
|
||||
}
|
||||
}
|
||||
j++;
|
||||
}
|
||||
|
||||
const tools: ToolUse[] = toolCalls.map((tc) => {
|
||||
const id = str(tc.id) ?? "";
|
||||
let resultSummary: string | undefined;
|
||||
let resultOutput: string | undefined;
|
||||
if (id && responsesById.has(id)) {
|
||||
const summarized = summarizeToolOutput(responsesById.get(id));
|
||||
resultSummary = summarized.summary;
|
||||
resultOutput = summarized.formattedOutput;
|
||||
}
|
||||
return {
|
||||
toolCallId: id,
|
||||
toolName: str(tc.name) ?? "",
|
||||
inputJson: JSON.stringify(tc.arguments ?? {}, null, 2),
|
||||
resultSummary,
|
||||
resultOutput,
|
||||
};
|
||||
});
|
||||
|
||||
items.push({ type: "tool-use", tools });
|
||||
i = j;
|
||||
continue;
|
||||
}
|
||||
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// tool-role messages are consumed via the assistant pairing above; skip stragglers.
|
||||
i++;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
import { rec, str, num, parseProviderMetadata, extractTelemetryMetadata } from "./aiHelpers";
|
||||
import type { AISpanData, DisplayItem } from "./types";
|
||||
|
||||
/**
|
||||
* Extracts structured AI data from top-level AI SDK parent spans.
|
||||
*
|
||||
* These spans (ai.generateText, ai.streamText, ai.generateObject, ai.streamObject)
|
||||
* use `ai.*` attributes instead of `gen_ai.*`. They contain the full prompt,
|
||||
* aggregated response, and total usage across all steps.
|
||||
*/
|
||||
export function extractAISummarySpanData(
|
||||
properties: Record<string, unknown>,
|
||||
durationMs: number
|
||||
): AISpanData | undefined {
|
||||
const ai = rec(properties.ai);
|
||||
if (!ai.operationId) return undefined;
|
||||
|
||||
// Skip child spans that have gen_ai.* (those use extractAISpanData)
|
||||
if (properties.gen_ai && typeof properties.gen_ai === "object") return undefined;
|
||||
|
||||
const aiModel = rec(ai.model);
|
||||
const aiResponse = rec(ai.response);
|
||||
const aiUsage = rec(ai.usage);
|
||||
const _aiSettings = rec(ai.settings);
|
||||
const _aiRequest = rec(ai.request);
|
||||
const aiTelemetry = rec(ai.telemetry);
|
||||
const trigger = rec(properties.trigger);
|
||||
const triggerLlm = rec(trigger.llm);
|
||||
|
||||
const model = str(aiModel.id);
|
||||
if (!model) return undefined;
|
||||
|
||||
const provider = str(aiModel.provider) ?? "unknown";
|
||||
const operationName = str(ai.operationId) ?? "";
|
||||
|
||||
// Token usage
|
||||
const inputTokens = num(aiUsage.inputTokens) ?? num(aiUsage.promptTokens) ?? 0;
|
||||
const outputTokens = num(aiUsage.outputTokens) ?? num(aiUsage.completionTokens) ?? 0;
|
||||
const totalTokens = num(aiUsage.totalTokens) ?? inputTokens + outputTokens;
|
||||
|
||||
const tokensPerSecond =
|
||||
outputTokens > 0 && durationMs > 0
|
||||
? Math.round((outputTokens / (durationMs / 1000)) * 10) / 10
|
||||
: undefined;
|
||||
|
||||
// Provider metadata
|
||||
const providerMeta = parseProviderMetadata(aiResponse.providerMetadata);
|
||||
|
||||
// Response ID from provider metadata
|
||||
const responseId = providerMeta?.responseId;
|
||||
|
||||
// Telemetry metadata (prompt info, custom metadata)
|
||||
const telemetryMetaRaw = rec(aiTelemetry.metadata);
|
||||
const promptMeta = rec(telemetryMetaRaw.prompt);
|
||||
const promptSlug = str(promptMeta.slug);
|
||||
const promptVersion = str(promptMeta.version);
|
||||
const promptModel = str(promptMeta.model);
|
||||
const promptLabels = str(promptMeta.labels);
|
||||
const promptInput = str(promptMeta.input);
|
||||
const telemetryMeta = extractTelemetryMetadata(aiTelemetry.metadata);
|
||||
|
||||
// Parse the prompt JSON to build display items
|
||||
const promptJson = str(ai.prompt);
|
||||
const items = promptJson
|
||||
? parsePromptToDisplayItems(promptJson, str(aiResponse.text))
|
||||
: undefined;
|
||||
|
||||
// Count messages from the parsed prompt
|
||||
let messageCount: number | undefined;
|
||||
if (promptJson) {
|
||||
try {
|
||||
const parsed = JSON.parse(promptJson) as Record<string, unknown>;
|
||||
if (parsed.messages && Array.isArray(parsed.messages)) {
|
||||
messageCount = parsed.messages.length;
|
||||
} else {
|
||||
// system + prompt = 2 messages
|
||||
messageCount = (parsed.system ? 1 : 0) + (parsed.prompt ? 1 : 0);
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
return {
|
||||
model,
|
||||
provider,
|
||||
operationName,
|
||||
responseId,
|
||||
finishReason: str(aiResponse.finishReason),
|
||||
serviceTier: providerMeta?.serviceTier,
|
||||
resolvedProvider: providerMeta?.resolvedProvider,
|
||||
toolChoice: undefined,
|
||||
toolCount: undefined,
|
||||
messageCount,
|
||||
telemetryMetadata: telemetryMeta,
|
||||
promptSlug: promptSlug || undefined,
|
||||
promptVersion: promptVersion || undefined,
|
||||
promptModel: promptModel || undefined,
|
||||
promptLabels: promptLabels || undefined,
|
||||
promptInput: promptInput || undefined,
|
||||
inputTokens,
|
||||
outputTokens,
|
||||
totalTokens,
|
||||
cachedTokens: num(aiUsage.cachedInputTokens),
|
||||
cacheCreationTokens: num(aiUsage.cacheCreationInputTokens),
|
||||
reasoningTokens: num(aiUsage.reasoningTokens),
|
||||
tokensPerSecond,
|
||||
msToFirstChunk: undefined, // Only on child doStream spans
|
||||
durationMs,
|
||||
inputCost: num(triggerLlm.input_cost),
|
||||
outputCost: num(triggerLlm.output_cost),
|
||||
totalCost: num(triggerLlm.total_cost),
|
||||
cachedCost: num(triggerLlm.cached_cost),
|
||||
cacheCreationCost: num(triggerLlm.cache_creation_cost),
|
||||
responseText: str(aiResponse.text) || undefined,
|
||||
responseObject: str(aiResponse.object) || undefined,
|
||||
toolDefinitions: undefined,
|
||||
items,
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Prompt parsing
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Parses the `ai.prompt` JSON string into display items.
|
||||
* Parent spans store the prompt as a JSON object with either:
|
||||
* - { system: "...", prompt: "..." }
|
||||
* - { system: "...", messages: [...] }
|
||||
* - { messages: [...] }
|
||||
*/
|
||||
function parsePromptToDisplayItems(
|
||||
promptJson: string,
|
||||
responseText?: string
|
||||
): DisplayItem[] | undefined {
|
||||
try {
|
||||
const parsed = JSON.parse(promptJson) as Record<string, unknown>;
|
||||
if (!parsed || typeof parsed !== "object") return undefined;
|
||||
|
||||
const items: DisplayItem[] = [];
|
||||
|
||||
if (typeof parsed.system === "string" && parsed.system) {
|
||||
items.push({ type: "system", text: parsed.system });
|
||||
}
|
||||
|
||||
if (typeof parsed.prompt === "string" && parsed.prompt) {
|
||||
items.push({ type: "user", text: parsed.prompt });
|
||||
}
|
||||
|
||||
if (Array.isArray(parsed.messages)) {
|
||||
for (const msg of parsed.messages) {
|
||||
if (!msg || typeof msg !== "object") continue;
|
||||
const m = msg as Record<string, unknown>;
|
||||
const role = m.role;
|
||||
const content = extractMessageContent(m.content);
|
||||
if (!content) continue;
|
||||
|
||||
switch (role) {
|
||||
case "system":
|
||||
items.push({ type: "system", text: content });
|
||||
break;
|
||||
case "user":
|
||||
items.push({ type: "user", text: content });
|
||||
break;
|
||||
case "assistant":
|
||||
items.push({ type: "assistant", text: content });
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Add response as assistant item if not already present
|
||||
if (responseText && !items.some((i) => i.type === "assistant")) {
|
||||
items.push({ type: "assistant", text: responseText });
|
||||
}
|
||||
|
||||
return items.length > 0 ? items : undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function extractMessageContent(content: unknown): string | undefined {
|
||||
if (typeof content === "string") return content;
|
||||
if (Array.isArray(content)) {
|
||||
// Extract text parts from content array [{type: "text", text: "..."}]
|
||||
return content
|
||||
.filter((p): p is { type: string; text: string } => {
|
||||
if (!p || typeof p !== "object") return false;
|
||||
const o = p as Record<string, unknown>;
|
||||
return o.type === "text" && typeof o.text === "string";
|
||||
})
|
||||
.map((p) => p.text)
|
||||
.join("\n");
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
export { AISpanDetails } from "./AISpanDetails";
|
||||
export { extractAISpanData } from "./extractAISpanData";
|
||||
export { extractAISummarySpanData } from "./extractAISummarySpanData";
|
||||
export { AIToolCallSpanDetails, extractAIToolCallData } from "./AIToolCallSpanDetails";
|
||||
export type { AIToolCallData } from "./AIToolCallSpanDetails";
|
||||
export { AIEmbedSpanDetails, extractAIEmbedData } from "./AIEmbedSpanDetails";
|
||||
export type { AIEmbedData } from "./AIEmbedSpanDetails";
|
||||
export type { AISpanData, DisplayItem, ToolUse } from "./types";
|
||||
@@ -0,0 +1,120 @@
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tool use (merged assistant tool-call + tool result)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type ToolDefinition = {
|
||||
name: string;
|
||||
description?: string;
|
||||
/** JSON schema as formatted string */
|
||||
parametersJson?: string;
|
||||
};
|
||||
|
||||
export type ToolUse = {
|
||||
toolCallId: string;
|
||||
toolName: string;
|
||||
/** Tool description from the definition, if available */
|
||||
description?: string;
|
||||
/** JSON schema of the tool's parameters, pretty-printed */
|
||||
parametersJson?: string;
|
||||
/** Formatted input args as JSON string */
|
||||
inputJson: string;
|
||||
/** Short summary of the result (e.g. "200 · text/html · truncated") */
|
||||
resultSummary?: string;
|
||||
/** Full formatted result for display in a code block */
|
||||
resultOutput?: string;
|
||||
/** Sub-agent output — when the tool result is a UIMessage with parts */
|
||||
subAgent?: {
|
||||
parts: any[];
|
||||
isStreaming: boolean;
|
||||
};
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Display items — what the UI actually renders
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** System prompt text (collapsible) */
|
||||
export type SystemItem = {
|
||||
type: "system";
|
||||
text: string;
|
||||
};
|
||||
|
||||
/** User message text */
|
||||
export type UserItem = {
|
||||
type: "user";
|
||||
text: string;
|
||||
};
|
||||
|
||||
/** One or more tool calls with their results, grouped */
|
||||
export type ToolUseItem = {
|
||||
type: "tool-use";
|
||||
tools: ToolUse[];
|
||||
};
|
||||
|
||||
/** Final assistant text response */
|
||||
export type AssistantItem = {
|
||||
type: "assistant";
|
||||
text: string;
|
||||
};
|
||||
|
||||
export type DisplayItem = SystemItem | UserItem | ToolUseItem | AssistantItem;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Span-level AI data
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type AISpanData = {
|
||||
model: string;
|
||||
provider: string;
|
||||
operationName: string;
|
||||
responseId?: string;
|
||||
|
||||
// Categorical tags
|
||||
finishReason?: string;
|
||||
serviceTier?: string;
|
||||
/** Resolved downstream provider for gateway/openrouter spans (e.g. "xAI", "mistral") */
|
||||
resolvedProvider?: string;
|
||||
toolChoice?: string;
|
||||
toolCount?: number;
|
||||
messageCount?: number;
|
||||
/** User-defined telemetry metadata (from ai.telemetry.metadata) */
|
||||
telemetryMetadata?: Record<string, string>;
|
||||
|
||||
// Prompt metadata (from ai.telemetry.metadata.prompt)
|
||||
promptSlug?: string;
|
||||
promptVersion?: string;
|
||||
promptModel?: string;
|
||||
promptLabels?: string;
|
||||
promptInput?: string;
|
||||
|
||||
// Token counts
|
||||
inputTokens: number;
|
||||
outputTokens: number;
|
||||
totalTokens: number;
|
||||
cachedTokens?: number;
|
||||
cacheCreationTokens?: number;
|
||||
reasoningTokens?: number;
|
||||
|
||||
// Performance
|
||||
tokensPerSecond?: number;
|
||||
msToFirstChunk?: number;
|
||||
durationMs: number;
|
||||
|
||||
// Cost
|
||||
inputCost?: number;
|
||||
outputCost?: number;
|
||||
totalCost?: number;
|
||||
cachedCost?: number;
|
||||
cacheCreationCost?: number;
|
||||
|
||||
// Response text (final assistant output)
|
||||
responseText?: string;
|
||||
// Structured object response (JSON) — mutually exclusive with responseText
|
||||
responseObject?: string;
|
||||
|
||||
// Tool definitions (from ai.prompt.tools)
|
||||
toolDefinitions?: ToolDefinition[];
|
||||
|
||||
// Display-ready message items (system, user, tool-use groups, assistant text)
|
||||
items?: DisplayItem[];
|
||||
};
|
||||
Reference in New Issue
Block a user