33import type { IAIService , IAIConversationService , ModelMessage } from '@objectstack/spec/contracts' ;
44import type { Logger } from '@objectstack/spec/contracts' ;
55import { encodeVercelDataStream } from '../stream/vercel-stream-encoder.js' ;
6+ import { normalizeMessage , validateMessageContent } from './message-utils.js' ;
67
78/**
89 * Minimal HTTP handler abstraction so routes stay framework-agnostic.
@@ -77,37 +78,6 @@ export interface RouteResponse {
7778/** Valid message roles accepted by the AI routes. */
7879const VALID_ROLES = new Set < string > ( [ 'system' , 'user' , 'assistant' , 'tool' ] ) ;
7980
80- /**
81- * Normalize a Vercel AI SDK v6 message (which may use `parts` instead of
82- * `content`) into a plain `{ role, content }` ModelMessage.
83- */
84- function normalizeMessage ( raw : Record < string , unknown > ) : ModelMessage {
85- const role = raw . role as string ;
86-
87- // If content is already a string, use it directly
88- if ( typeof raw . content === 'string' ) {
89- return { role, content : raw . content } as unknown as ModelMessage ;
90- }
91-
92- // If content is an array (multi-part), pass through
93- if ( Array . isArray ( raw . content ) ) {
94- return { role, content : raw . content } as unknown as ModelMessage ;
95- }
96-
97- // Vercel AI SDK v6: extract text from `parts` array
98- if ( Array . isArray ( raw . parts ) ) {
99- const textParts = ( raw . parts as Array < Record < string , unknown > > )
100- . filter ( p => p . type === 'text' && typeof p . text === 'string' )
101- . map ( p => p . text as string ) ;
102- if ( textParts . length > 0 ) {
103- return { role, content : textParts . join ( '' ) } as unknown as ModelMessage ;
104- }
105- }
106-
107- // Fallback: empty content (e.g. tool-only assistant messages)
108- return { role, content : '' } as unknown as ModelMessage ;
109- }
110-
11181/**
11282 * Validate that `raw` is a well-formed message.
11383 * Returns null on success, or an error string on failure.
@@ -125,44 +95,10 @@ function validateMessage(raw: unknown): string | null {
12595 if ( typeof msg . role !== 'string' || ! VALID_ROLES . has ( msg . role ) ) {
12696 return `message.role must be one of ${ [ ...VALID_ROLES ] . map ( r => `"${ r } "` ) . join ( ', ' ) } ` ;
12797 }
128- const content = msg . content ;
129-
130- // Vercel AI SDK v6 sends `parts` instead of (or alongside) `content`.
131- // Accept any message that carries a `parts` array, even when `content` is absent.
132- if ( Array . isArray ( msg . parts ) ) {
133- return null ;
134- }
135-
136- // content is a plain string — OK
137- if ( typeof content === 'string' ) {
138- return null ;
139- }
140-
141- // content is an array of typed parts (legacy multi-part format)
142- if ( Array . isArray ( content ) ) {
143- for ( const part of content as unknown [ ] ) {
144- if ( typeof part !== 'object' || part === null ) {
145- return 'message.content array elements must be non-null objects' ;
146- }
147- const partObj = part as Record < string , unknown > ;
148- if ( typeof partObj . type !== 'string' ) {
149- return 'each message.content array element must have a string "type" property' ;
150- }
151- if ( partObj . type === 'text' && typeof partObj . text !== 'string' ) {
152- return 'message.content elements with type "text" must have a string "text" property' ;
153- }
154- }
155- return null ;
156- }
15798
15899 // Assistant / tool messages may legitimately have null or missing content
159- if ( content === null || content === undefined ) {
160- if ( msg . role === 'assistant' || msg . role === 'tool' ) {
161- return null ;
162- }
163- }
164-
165- return 'message.content must be a string, an array, or include parts' ;
100+ const allowEmpty = msg . role === 'assistant' || msg . role === 'tool' ;
101+ return validateMessageContent ( msg , { allowEmptyContent : allowEmpty } ) ;
166102}
167103
168104/**
0 commit comments