chore: import upstream snapshot with attribution

This commit is contained in:
wehub-resource-sync
2026-07-13 13:32:57 +08:00
commit cd420f9332
4811 changed files with 884702 additions and 0 deletions
@@ -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[];
};