Skip to content

Commit ca2b14f

Browse files
committed
feat(ai): enhance message normalization and validation for Vercel AI SDK v6
1 parent 117cc38 commit ca2b14f

File tree

1 file changed

+57
-7
lines changed

1 file changed

+57
-7
lines changed

packages/services/service-ai/src/routes/ai-routes.ts

Lines changed: 57 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -76,12 +76,45 @@ export interface RouteResponse {
7676
/** Valid message roles accepted by the AI routes. */
7777
const VALID_ROLES = new Set<string>(['system', 'user', 'assistant', 'tool']);
7878

79+
/**
80+
* Normalize a Vercel AI SDK v6 message (which may use `parts` instead of
81+
* `content`) into a plain `{ role, content }` ModelMessage.
82+
*/
83+
function normalizeMessage(raw: Record<string, unknown>): ModelMessage {
84+
const role = raw.role as string;
85+
86+
// If content is already a string, use it directly
87+
if (typeof raw.content === 'string') {
88+
return { role, content: raw.content } as unknown as ModelMessage;
89+
}
90+
91+
// If content is an array (multi-part), pass through
92+
if (Array.isArray(raw.content)) {
93+
return { role, content: raw.content } as unknown as ModelMessage;
94+
}
95+
96+
// Vercel AI SDK v6: extract text from `parts` array
97+
if (Array.isArray(raw.parts)) {
98+
const textParts = (raw.parts as Array<Record<string, unknown>>)
99+
.filter(p => p.type === 'text' && typeof p.text === 'string')
100+
.map(p => p.text as string);
101+
if (textParts.length > 0) {
102+
return { role, content: textParts.join('') } as unknown as ModelMessage;
103+
}
104+
}
105+
106+
// Fallback: empty content (e.g. tool-only assistant messages)
107+
return { role, content: '' } as unknown as ModelMessage;
108+
}
109+
79110
/**
80111
* Validate that `raw` is a well-formed message.
81112
* Returns null on success, or an error string on failure.
82113
*
83-
* Accepts both simple string content (legacy) and Vercel AI SDK array content
84-
* (e.g. `[{ type: 'text', text: '...' }]`).
114+
* Accepts:
115+
* - Simple string `content` (legacy)
116+
* - Array `content` (e.g. `[{ type: 'text', text: '...' }]`)
117+
* - Vercel AI SDK v6 `parts` format (content may be absent/null)
85118
*/
86119
function validateMessage(raw: unknown): string | null {
87120
if (typeof raw !== 'object' || raw === null) {
@@ -92,12 +125,21 @@ function validateMessage(raw: unknown): string | null {
92125
return `message.role must be one of ${[...VALID_ROLES].map(r => `"${r}"`).join(', ')}`;
93126
}
94127
const content = msg.content;
128+
129+
// Vercel AI SDK v6 sends `parts` instead of (or alongside) `content`.
130+
// Accept any message that carries a `parts` array, even when `content` is absent.
131+
if (Array.isArray(msg.parts)) {
132+
return null;
133+
}
134+
135+
// content is a plain string — OK
95136
if (typeof content === 'string') {
96137
return null;
97138
}
139+
140+
// content is an array of typed parts (legacy multi-part format)
98141
if (Array.isArray(content)) {
99-
const parts = content as unknown[];
100-
for (const part of parts) {
142+
for (const part of content as unknown[]) {
101143
if (typeof part !== 'object' || part === null) {
102144
return 'message.content array elements must be non-null objects';
103145
}
@@ -111,7 +153,15 @@ function validateMessage(raw: unknown): string | null {
111153
}
112154
return null;
113155
}
114-
return 'message.content must be a string or an array';
156+
157+
// Assistant / tool messages may legitimately have null or missing content
158+
if (content === null || content === undefined) {
159+
if (msg.role === 'assistant' || msg.role === 'tool') {
160+
return null;
161+
}
162+
}
163+
164+
return 'message.content must be a string, an array, or include parts';
115165
}
116166

117167
/**
@@ -192,7 +242,7 @@ export function buildAIRoutes(
192242
...(systemPrompt
193243
? [{ role: 'system' as const, content: systemPrompt }]
194244
: []),
195-
...(messages as ModelMessage[]),
245+
...messages.map(m => normalizeMessage(m as Record<string, unknown>)),
196246
];
197247

198248
// ── Choose response mode ─────────────────────────────
@@ -249,7 +299,7 @@ export function buildAIRoutes(
249299
if (!aiService.streamChat) {
250300
return { status: 501, body: { error: 'Streaming is not supported by the configured AI service' } };
251301
}
252-
const events = aiService.streamChat(messages as ModelMessage[], options as any);
302+
const events = aiService.streamChat(messages.map(m => normalizeMessage(m as Record<string, unknown>)), options as any);
253303
return { status: 200, stream: true, events };
254304
} catch (err) {
255305
logger.error('[AI Route] /chat/stream error', err instanceof Error ? err : undefined);

0 commit comments

Comments
 (0)