Skip to content

Commit a9773f2

Browse files
fix: added support for AI SDK v2 in AI Mode (#1216)
1 parent ac52336 commit a9773f2

File tree

5 files changed

+146
-27
lines changed

5 files changed

+146
-27
lines changed

.changeset/hungry-terms-smell.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@spotlightjs/spotlight": patch
3+
---
4+
5+
added support for AI SDK v2 in AI Mode

packages/spotlight/src/ui/telemetry/components/insights/aiTraces/AITraceDetails.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -158,7 +158,8 @@ function PromptSection({ trace }: { trace: SpotlightAITrace }) {
158158
}
159159
messageContent = trace.response.text;
160160
} else {
161-
messageContent = message.content;
161+
// Content is normalized to string by vercelAISDK handler
162+
messageContent = typeof message.content === "string" ? message.content : "";
162163
}
163164

164165
return (

packages/spotlight/src/ui/telemetry/components/insights/aiTraces/AITranscription.tsx

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,18 @@ function parseAITracesToConversation(aiTraces: SpotlightAITrace[]): Conversation
4545
if (aiTrace.prompt?.messages) {
4646
const userMessages = aiTrace.prompt.messages
4747
.filter(msg => msg.role === "user" || msg.role === "human")
48-
.map(msg => msg.content)
48+
.map(msg => {
49+
let content = msg.content;
50+
51+
if (Array.isArray(content)) {
52+
content = content
53+
.filter(item => item.type === "text" && item.text)
54+
.map(item => item.text)
55+
.join("");
56+
}
57+
58+
return content;
59+
})
4960
.filter(content => content?.trim())
5061
.join("\n\n");
5162

packages/spotlight/src/ui/telemetry/components/insights/aiTraces/sdks/vercelAISDK.ts

Lines changed: 118 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -27,9 +27,22 @@ const GEN_AI_RESPONSE_FINISH_REASONS_FIELD = "gen_ai.response.finish_reasons";
2727
const AI_RESPONSE_TEXT_FIELD = "gen_ai.response.text";
2828
const AI_RESPONSE_TOOL_CALLS_FIELD = "vercel.ai.response.toolCalls";
2929

30+
// v1 operation names
3031
const AI_STREAM_TEXT_OPERATION = "ai.streamText";
3132
const AI_GENERATE_TEXT_OPERATION = "ai.generateText";
3233

34+
// v2 operation names (OpenTelemetry semantic conventions)
35+
const AI_INVOKE_AGENT_OPERATION = "gen_ai.invoke_agent";
36+
const AI_STREAM_TEXT_V2_OPERATION = "gen_ai.stream_text";
37+
38+
// v2 field names
39+
const GEN_AI_PROMPT_FIELD = "gen_ai.prompt";
40+
const GEN_AI_FUNCTION_ID_FIELD = "gen_ai.function_id";
41+
const GEN_AI_REQUEST_MODEL_FIELD = "gen_ai.request.model";
42+
const GEN_AI_RESPONSE_MODEL_FIELD = "gen_ai.response.model";
43+
const GEN_AI_SYSTEM_FIELD = "gen_ai.system";
44+
const GEN_AI_OPERATION_NAME_FIELD = "gen_ai.operation.name";
45+
3346
const AI_USAGE_PROMPT_TOKENS_FIELD = "vercel.ai.usage.promptTokens";
3447
const AI_USAGE_COMPLETION_TOKENS_FIELD = "vercel.ai.usage.completionTokens";
3548
const GEN_AI_USAGE_INPUT_TOKENS_FIELD = "gen_ai.usage.input_tokens";
@@ -44,6 +57,41 @@ const TOKEN_FIELDS = {
4457
const DEFAULT_TRACE_NAME = "AI Interaction";
4558
const UNKNOWN_OPERATION = "N/A";
4659

60+
// Operation to badge mapping
61+
const OPERATION_BADGE_MAP = new Map<string, string>([
62+
[AI_STREAM_TEXT_OPERATION, "Stream Text"],
63+
[AI_GENERATE_TEXT_OPERATION, "Generate Text"],
64+
[AI_INVOKE_AGENT_OPERATION, "Invoke Agent"],
65+
[AI_STREAM_TEXT_V2_OPERATION, "Stream Text"],
66+
]);
67+
68+
// Field mappings: first element is v2, subsequent are v1 alternatives
69+
const AI_FIELD_MAPPINGS = {
70+
MODEL_ID: [AI_MODEL_ID_FIELD, GEN_AI_REQUEST_MODEL_FIELD, GEN_AI_RESPONSE_MODEL_FIELD],
71+
MODEL_PROVIDER: [AI_MODEL_PROVIDER_FIELD, GEN_AI_SYSTEM_FIELD],
72+
FUNCTION_ID: [AI_TELEMETRY_FUNCTION_ID_FIELD, GEN_AI_FUNCTION_ID_FIELD],
73+
PROMPT_TOKENS: [AI_USAGE_PROMPT_TOKENS_FIELD, GEN_AI_USAGE_INPUT_TOKENS_FIELD],
74+
COMPLETION_TOKENS: [AI_USAGE_COMPLETION_TOKENS_FIELD, GEN_AI_USAGE_OUTPUT_TOKENS_FIELD],
75+
PROMPT: [GEN_AI_PROMPT_FIELD, AI_PROMPT_FIELD],
76+
OPERATION_ID: [AI_OPERATION_ID_FIELD, AI_OPERATION_NAME_FIELD, GEN_AI_OPERATION_NAME_FIELD],
77+
} as const;
78+
79+
/**
80+
* Extracts a field value from span data, checking v1 fields first then v2 alternatives.
81+
*/
82+
function extractFieldFromSpan(
83+
data: Record<string, unknown>,
84+
fieldKey: keyof typeof AI_FIELD_MAPPINGS,
85+
): unknown | undefined {
86+
const fields = AI_FIELD_MAPPINGS[fieldKey];
87+
for (const field of fields) {
88+
if (data[field] !== undefined) {
89+
return data[field];
90+
}
91+
}
92+
return undefined;
93+
}
94+
4795
export const vercelAISDKHandler: AILibraryHandler = {
4896
id: "vercel-ai-sdk",
4997
name: "Vercel AI SDK",
@@ -110,15 +158,7 @@ export const vercelAISDKHandler: AILibraryHandler = {
110158
return "Tool-Call";
111159
}
112160

113-
if (trace.operation === AI_STREAM_TEXT_OPERATION) {
114-
return "Stream Text";
115-
}
116-
117-
if (trace.operation === AI_GENERATE_TEXT_OPERATION) {
118-
return "Generate Text";
119-
}
120-
121-
return trace.operation.replace(/^(ai\.|gen_ai\.)/, "");
161+
return OPERATION_BADGE_MAP.get(trace.operation) ?? trace.operation.replace(/^(ai\.|gen_ai\.)/, "");
122162
},
123163

124164
getTokensDisplay: (trace: SpotlightAITrace): string => {
@@ -177,7 +217,7 @@ function determineOperation(
177217

178218
if (!span.data) continue;
179219

180-
const operationId = (span.data[AI_OPERATION_ID_FIELD] || span.data[AI_OPERATION_NAME_FIELD]) as string | undefined;
220+
const operationId = extractFieldFromSpan(span.data, "OPERATION_ID") as string | undefined;
181221

182222
// Handle tool call operation
183223
if (operationId === AI_TOOL_CALL_OPERATION) {
@@ -234,6 +274,28 @@ function formatTokensDisplay(promptTokens?: number, completionTokens?: number):
234274
return UNKNOWN_OPERATION;
235275
}
236276

277+
/**
278+
* Normalizes message content from v1 (string) or v2 (array of content blocks) format to a string.
279+
* v1: content = "text string"
280+
* v2: content = [{ type: "text", text: "text string" }]
281+
*
282+
* Only extracts text content blocks. Other block types (image, file, tool-call)
283+
* would require UI changes to display properly and are filtered out for now.
284+
*/
285+
function normalizeMessageContent(content: unknown): string {
286+
if (typeof content === "string") return content;
287+
if (Array.isArray(content)) {
288+
// Only extract text content - images/files would need UI changes to display
289+
return content
290+
.filter(
291+
(block): block is { type: string; text: string } => block.type === "text" && typeof block.text === "string",
292+
)
293+
.map(block => block.text)
294+
.join("");
295+
}
296+
return "";
297+
}
298+
237299
function parseSpanData(spans: Span[], trace: SpotlightAITrace) {
238300
for (const span of spans) {
239301
if (!span.data) continue;
@@ -249,16 +311,28 @@ function parseSpanData(spans: Span[], trace: SpotlightAITrace) {
249311
function extractAIMetadata(span: Span, trace: SpotlightAITrace) {
250312
if (!span.data) return;
251313

252-
if (span.data[AI_MODEL_ID_FIELD]) {
253-
trace.metadata.modelId = String(span.data[AI_MODEL_ID_FIELD]);
314+
// Model ID
315+
if (trace.metadata.modelId === undefined) {
316+
const modelId = extractFieldFromSpan(span.data, "MODEL_ID");
317+
if (modelId !== undefined) {
318+
trace.metadata.modelId = String(modelId);
319+
}
254320
}
255321

256-
if (span.data[AI_MODEL_PROVIDER_FIELD]) {
257-
trace.metadata.modelProvider = String(span.data[AI_MODEL_PROVIDER_FIELD]);
322+
// Model Provider
323+
if (trace.metadata.modelProvider === undefined) {
324+
const provider = extractFieldFromSpan(span.data, "MODEL_PROVIDER");
325+
if (provider !== undefined) {
326+
trace.metadata.modelProvider = String(provider);
327+
}
258328
}
259329

260-
if (span.data[AI_TELEMETRY_FUNCTION_ID_FIELD]) {
261-
trace.metadata.functionId = String(span.data[AI_TELEMETRY_FUNCTION_ID_FIELD]);
330+
// Function ID
331+
if (trace.metadata.functionId === undefined) {
332+
const functionId = extractFieldFromSpan(span.data, "FUNCTION_ID");
333+
if (functionId !== undefined) {
334+
trace.metadata.functionId = String(functionId);
335+
}
262336
}
263337

264338
if (span.data[AI_SETTINGS_MAX_RETRIES_FIELD]) {
@@ -269,12 +343,19 @@ function extractAIMetadata(span: Span, trace: SpotlightAITrace) {
269343
trace.metadata.maxSteps = Number(span.data[AI_SETTINGS_MAX_STEPS_FIELD]);
270344
}
271345

272-
if (span.data[AI_USAGE_PROMPT_TOKENS_FIELD]) {
273-
trace.metadata.promptTokens = Number(span.data[AI_USAGE_PROMPT_TOKENS_FIELD]);
346+
// Token usage - use === undefined to allow 0 values
347+
if (trace.metadata.promptTokens === undefined) {
348+
const promptTokens = extractFieldFromSpan(span.data, "PROMPT_TOKENS");
349+
if (promptTokens !== undefined) {
350+
trace.metadata.promptTokens = Number(promptTokens);
351+
}
274352
}
275353

276-
if (span.data[AI_USAGE_COMPLETION_TOKENS_FIELD]) {
277-
trace.metadata.completionTokens = Number(span.data[AI_USAGE_COMPLETION_TOKENS_FIELD]);
354+
if (trace.metadata.completionTokens === undefined) {
355+
const completionTokens = extractFieldFromSpan(span.data, "COMPLETION_TOKENS");
356+
if (completionTokens !== undefined) {
357+
trace.metadata.completionTokens = Number(completionTokens);
358+
}
278359
}
279360
}
280361

@@ -292,10 +373,18 @@ function extractTelemetryMetadata(span: Span, trace: SpotlightAITrace) {
292373
function extractPromptData(span: Span, trace: SpotlightAITrace) {
293374
if (!span.data) return;
294375

295-
const promptField = span.data[AI_PROMPT_FIELD];
296-
if (promptField) {
376+
const promptField = extractFieldFromSpan(span.data, "PROMPT");
377+
if (promptField && !trace.prompt) {
297378
try {
298-
trace.prompt = JSON.parse(String(promptField));
379+
const parsed = JSON.parse(String(promptField));
380+
// Normalize message content from v2 array format to string
381+
if (parsed.messages && Array.isArray(parsed.messages)) {
382+
parsed.messages = parsed.messages.map((msg: { role: string; content: unknown }) => ({
383+
...msg,
384+
content: normalizeMessageContent(msg.content),
385+
}));
386+
}
387+
trace.prompt = parsed;
299388
} catch {
300389
trace.prompt = { messages: [{ role: "unknown", content: String(promptField) }] };
301390
}
@@ -305,7 +394,12 @@ function extractPromptData(span: Span, trace: SpotlightAITrace) {
305394
if (promptMessages && !trace.prompt) {
306395
try {
307396
const messages = JSON.parse(String(promptMessages));
308-
trace.prompt = { messages };
397+
// Normalize message content from v2 array format to string
398+
const normalizedMessages = messages.map((msg: { role: string; content: unknown }) => ({
399+
...msg,
400+
content: normalizeMessageContent(msg.content),
401+
}));
402+
trace.prompt = { messages: normalizedMessages };
309403
} catch {
310404
trace.prompt = { messages: [{ role: "unknown", content: String(promptMessages) }] };
311405
}

packages/spotlight/src/ui/telemetry/types.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -380,9 +380,17 @@ export type AIToolCall = {
380380
step?: number;
381381
};
382382

383+
// Content can be string (v1) or array of content blocks (v2)
384+
export type AIContentBlock = {
385+
type: string;
386+
text?: string;
387+
};
388+
389+
export type AIMessageContent = string | AIContentBlock[];
390+
383391
export type AIMessage = {
384392
role: string;
385-
content: string;
393+
content: AIMessageContent;
386394
toolInvocations?: AIToolCall[];
387395
parts?: unknown[];
388396
};

0 commit comments

Comments
 (0)