@objectstack/service-ai 4.0.2 → 4.0.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,191 @@
1
+ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2
+
3
+ import { describe, it, expect, beforeEach, vi } from 'vitest';
4
+ import { buildToolRoutes } from '../routes/tool-routes.js';
5
+ import { AIService } from '../ai-service.js';
6
+ import { InMemoryConversationService } from '../conversation/in-memory-conversation-service.js';
7
+ import { ToolRegistry } from '../tools/tool-registry.js';
8
+ import type { Logger } from '@objectstack/spec/contracts';
9
+
10
+ const silentLogger: Logger = {
11
+ debug: vi.fn(),
12
+ info: vi.fn(),
13
+ warn: vi.fn(),
14
+ error: vi.fn(),
15
+ fatal: vi.fn(),
16
+ };
17
+
18
+ describe('Tool Routes', () => {
19
+ let aiService: AIService;
20
+ let routes: ReturnType<typeof buildToolRoutes>;
21
+
22
+ beforeEach(() => {
23
+ const conversationService = new InMemoryConversationService();
24
+ aiService = new AIService({
25
+ adapter: 'memory',
26
+ conversationService,
27
+ });
28
+
29
+ // Register a test tool
30
+ aiService.toolRegistry.register(
31
+ {
32
+ name: 'test_tool',
33
+ description: 'A test tool for playground',
34
+ parameters: {
35
+ type: 'object',
36
+ properties: {
37
+ message: { type: 'string' },
38
+ },
39
+ required: ['message'],
40
+ },
41
+ },
42
+ async (params: any) => {
43
+ return JSON.stringify({ echo: params.message });
44
+ }
45
+ );
46
+
47
+ routes = buildToolRoutes(aiService, silentLogger);
48
+ });
49
+
50
+ describe('GET /api/v1/ai/tools', () => {
51
+ it('should list all registered tools', async () => {
52
+ const listRoute = routes.find(r => r.method === 'GET' && r.path === '/api/v1/ai/tools');
53
+ expect(listRoute).toBeDefined();
54
+
55
+ const response = await listRoute!.handler({});
56
+ expect(response.status).toBe(200);
57
+ expect(response.body).toHaveProperty('tools');
58
+ expect(Array.isArray((response.body as any).tools)).toBe(true);
59
+
60
+ const tools = (response.body as any).tools;
61
+ expect(tools.length).toBeGreaterThan(0);
62
+ expect(tools.some((t: any) => t.name === 'test_tool')).toBe(true);
63
+ });
64
+
65
+ it('should require authentication', () => {
66
+ const listRoute = routes.find(r => r.method === 'GET' && r.path === '/api/v1/ai/tools');
67
+ expect(listRoute?.auth).toBe(true);
68
+ expect(listRoute?.permissions).toContain('ai:tools');
69
+ });
70
+ });
71
+
72
+ describe('POST /api/v1/ai/tools/:toolName/execute', () => {
73
+ it('should execute a tool with parameters', async () => {
74
+ const executeRoute = routes.find(
75
+ r => r.method === 'POST' && r.path === '/api/v1/ai/tools/:toolName/execute'
76
+ );
77
+ expect(executeRoute).toBeDefined();
78
+
79
+ const response = await executeRoute!.handler({
80
+ params: { toolName: 'test_tool' },
81
+ body: {
82
+ parameters: { message: 'Hello, Playground!' },
83
+ },
84
+ });
85
+
86
+ expect(response.status).toBe(200);
87
+ expect(response.body).toHaveProperty('result');
88
+ // Result is a JSON string from the handler
89
+ expect((response.body as any).result).toBe('{"echo":"Hello, Playground!"}');
90
+ expect((response.body as any).toolName).toBe('test_tool');
91
+ expect((response.body as any).duration).toBeTypeOf('number');
92
+ });
93
+
94
+ it('should return 404 for non-existent tool', async () => {
95
+ const executeRoute = routes.find(
96
+ r => r.method === 'POST' && r.path === '/api/v1/ai/tools/:toolName/execute'
97
+ );
98
+
99
+ const response = await executeRoute!.handler({
100
+ params: { toolName: 'non_existent_tool' },
101
+ body: {
102
+ parameters: {},
103
+ },
104
+ });
105
+
106
+ expect(response.status).toBe(404);
107
+ expect((response.body as any).error).toContain('not found');
108
+ });
109
+
110
+ it('should return 400 when toolName is missing', async () => {
111
+ const executeRoute = routes.find(
112
+ r => r.method === 'POST' && r.path === '/api/v1/ai/tools/:toolName/execute'
113
+ );
114
+
115
+ const response = await executeRoute!.handler({
116
+ body: {
117
+ parameters: {},
118
+ },
119
+ });
120
+
121
+ expect(response.status).toBe(400);
122
+ expect((response.body as any).error).toContain('toolName');
123
+ });
124
+
125
+ it('should return 400 when parameters are missing', async () => {
126
+ const executeRoute = routes.find(
127
+ r => r.method === 'POST' && r.path === '/api/v1/ai/tools/:toolName/execute'
128
+ );
129
+
130
+ const response = await executeRoute!.handler({
131
+ params: { toolName: 'test_tool' },
132
+ body: {},
133
+ });
134
+
135
+ expect(response.status).toBe(400);
136
+ expect((response.body as any).error).toContain('parameters');
137
+ });
138
+
139
+ it('should handle tool execution errors', async () => {
140
+ // Register a tool that throws an error
141
+ aiService.toolRegistry.register(
142
+ {
143
+ name: 'error_tool',
144
+ description: 'A tool that throws an error',
145
+ parameters: { type: 'object', properties: {} },
146
+ },
147
+ async () => {
148
+ throw new Error('Tool execution failed');
149
+ }
150
+ );
151
+
152
+ const executeRoute = routes.find(
153
+ r => r.method === 'POST' && r.path === '/api/v1/ai/tools/:toolName/execute'
154
+ );
155
+
156
+ const response = await executeRoute!.handler({
157
+ params: { toolName: 'error_tool' },
158
+ body: {
159
+ parameters: {},
160
+ },
161
+ });
162
+
163
+ expect(response.status).toBe(500);
164
+ expect((response.body as any).error).toContain('Tool execution failed');
165
+ expect((response.body as any).duration).toBeTypeOf('number');
166
+ });
167
+
168
+ it('should require authentication and permissions', () => {
169
+ const executeRoute = routes.find(
170
+ r => r.method === 'POST' && r.path === '/api/v1/ai/tools/:toolName/execute'
171
+ );
172
+
173
+ expect(executeRoute?.auth).toBe(true);
174
+ expect(executeRoute?.permissions).toContain('ai:tools');
175
+ expect(executeRoute?.permissions).toContain('ai:execute');
176
+ });
177
+ });
178
+
179
+ describe('Route Configuration', () => {
180
+ it('should register exactly 2 routes', () => {
181
+ expect(routes).toHaveLength(2);
182
+ });
183
+
184
+ it('should have descriptive route descriptions', () => {
185
+ routes.forEach(route => {
186
+ expect(route.description).toBeTruthy();
187
+ expect(route.description.length).toBeGreaterThan(10);
188
+ });
189
+ });
190
+ });
191
+ });
@@ -128,6 +128,53 @@ describe('encodeStreamPart', () => {
128
128
  const part = { type: 'unknown-internal' } as unknown as TextStreamPart<ToolSet>;
129
129
  expect(encodeStreamPart(part)).toBe('');
130
130
  });
131
+
132
+ it('should encode reasoning-start part with g: prefix', () => {
133
+ const part = {
134
+ type: 'reasoning-start',
135
+ id: 'r1',
136
+ } as unknown as TextStreamPart<ToolSet>;
137
+
138
+ const frame = encodeStreamPart(part);
139
+ expect(frame).toBe('g:{"text":""}\n');
140
+ });
141
+
142
+ it('should encode reasoning-delta part with g: prefix', () => {
143
+ const part = {
144
+ type: 'reasoning-delta',
145
+ id: 'r1',
146
+ text: 'Let me think through this step by step...',
147
+ } as unknown as TextStreamPart<ToolSet>;
148
+
149
+ const frame = encodeStreamPart(part);
150
+ expect(frame).toBe('g:{"text":"Let me think through this step by step..."}\n');
151
+ });
152
+
153
+ it('should encode reasoning-end part as empty (no specific end marker)', () => {
154
+ const part = {
155
+ type: 'reasoning-end',
156
+ id: 'r1',
157
+ } as unknown as TextStreamPart<ToolSet>;
158
+
159
+ const frame = encodeStreamPart(part);
160
+ expect(frame).toBe('');
161
+ });
162
+
163
+ it('should pass through custom step events', () => {
164
+ const part = {
165
+ type: 'step-start',
166
+ stepId: 'step_1',
167
+ stepName: 'Query database',
168
+ } as unknown as TextStreamPart<ToolSet>;
169
+
170
+ const frame = encodeStreamPart(part);
171
+ const payload = parseSSE(frame);
172
+ expect(payload).toEqual({
173
+ type: 'step-start',
174
+ stepId: 'step_1',
175
+ stepName: 'Query database',
176
+ });
177
+ });
131
178
  });
132
179
 
133
180
  // ─────────────────────────────────────────────────────────────────
@@ -36,8 +36,8 @@ Capabilities:
36
36
  - Describe the full schema of a specific object
37
37
 
38
38
  Guidelines:
39
- 1. Before creating a new object, use list_metadata_objects to check if a similar one already exists.
40
- 2. Before modifying or deleting fields, use describe_metadata_object to understand the current schema.
39
+ 1. Before creating a new object, use list_objects to check if a similar one already exists.
40
+ 2. Before modifying or deleting fields, use describe_object to understand the current schema.
41
41
  3. Always use snake_case for object names and field names (e.g. project_task, due_date).
42
42
  4. Suggest meaningful field types based on the user's description (e.g. "deadline" → date, "active" → boolean).
43
43
  5. When creating objects, propose a reasonable set of initial fields based on the entity type.
@@ -59,8 +59,8 @@ Guidelines:
59
59
  { type: 'action', name: 'add_field', description: 'Add a field to an existing object' },
60
60
  { type: 'action', name: 'modify_field', description: 'Modify an existing field definition' },
61
61
  { type: 'action', name: 'delete_field', description: 'Delete a field from an object' },
62
- { type: 'query', name: 'list_metadata_objects', description: 'List all metadata objects' },
63
- { type: 'query', name: 'describe_metadata_object', description: 'Describe an object schema' },
62
+ { type: 'query', name: 'list_objects', description: 'List all data objects' },
63
+ { type: 'query', name: 'describe_object', description: 'Describe an object schema' },
64
64
  ],
65
65
 
66
66
  active: true,
package/src/ai-service.ts CHANGED
@@ -332,6 +332,13 @@ export class AIService implements IAIService {
332
332
  }
333
333
  }
334
334
  }
335
+ // Emit tool-result so the client can see tool output via SSE
336
+ yield {
337
+ type: 'tool-result',
338
+ toolCallId: tr.toolCallId,
339
+ toolName: tr.toolName,
340
+ output: tr.output,
341
+ } as TextStreamPart<ToolSet>;
335
342
  conversation.push({
336
343
  role: 'tool',
337
344
  content: [tr],
package/src/index.ts CHANGED
@@ -39,8 +39,8 @@ export {
39
39
  addFieldTool,
40
40
  modifyFieldTool,
41
41
  deleteFieldTool,
42
- listMetadataObjectsTool,
43
- describeMetadataObjectTool,
42
+ listObjectsTool,
43
+ describeObjectTool,
44
44
  } from './tools/metadata-tools.js';
45
45
 
46
46
  // Agent runtime
@@ -56,4 +56,5 @@ export { AiConversationObject, AiMessageObject } from './objects/index.js';
56
56
  // Routes
57
57
  export { buildAIRoutes } from './routes/ai-routes.js';
58
58
  export { buildAgentRoutes } from './routes/agent-routes.js';
59
+ export { buildToolRoutes } from './routes/tool-routes.js';
59
60
  export type { RouteDefinition, RouteRequest, RouteResponse, RouteUserContext } from './routes/ai-routes.js';
package/src/plugin.ts CHANGED
@@ -6,6 +6,7 @@ import { AIService } from './ai-service.js';
6
6
  import type { AIServiceConfig } from './ai-service.js';
7
7
  import { buildAIRoutes } from './routes/ai-routes.js';
8
8
  import { buildAgentRoutes } from './routes/agent-routes.js';
9
+ import { buildToolRoutes } from './routes/tool-routes.js';
9
10
  import { ObjectQLConversationService } from './conversation/objectql-conversation-service.js';
10
11
  import { AiConversationObject, AiMessageObject } from './objects/index.js';
11
12
  import { registerDataTools } from './tools/data-tools.js';
@@ -230,8 +231,8 @@ export class AIServicePlugin implements Plugin {
230
231
  setupNav.contribute({
231
232
  areaId: 'area_ai',
232
233
  items: [
233
- { id: 'nav_ai_conversations', type: 'object', label: { key: 'setup.nav.ai_conversations', defaultValue: 'Conversations' }, objectName: 'conversations', icon: 'message-square', order: 10 },
234
- { id: 'nav_ai_messages', type: 'object', label: { key: 'setup.nav.ai_messages', defaultValue: 'Messages' }, objectName: 'messages', icon: 'messages-square', order: 20 },
234
+ { id: 'nav_ai_conversations', type: 'object', label: 'Conversations', objectName: 'conversations', icon: 'message-square', order: 10 },
235
+ { id: 'nav_ai_messages', type: 'object', label: 'Messages', objectName: 'messages', icon: 'messages-square', order: 20 },
235
236
  ],
236
237
  });
237
238
  ctx.logger.info('[AI] Navigation items contributed to Setup App');
@@ -250,28 +251,54 @@ export class AIServicePlugin implements Plugin {
250
251
  let metadataService: IMetadataService | undefined;
251
252
  try {
252
253
  metadataService = ctx.getService<IMetadataService>('metadata');
253
- } catch {
254
+ console.log('[AI Plugin] Retrieved metadata service:', !!metadataService, 'has getRegisteredTypes:', typeof (metadataService as any)?.getRegisteredTypes);
255
+ } catch (e: any) {
256
+ console.log('[AI] Metadata service not available:', e.message);
254
257
  ctx.logger.debug('[AI] Metadata service not available');
255
258
  }
256
259
 
257
- // Data tools require both data engine and metadata service
260
+ // Data tools require only the data engine
258
261
  try {
259
262
  const dataEngine = ctx.getService<IDataEngine>('data');
260
- if (dataEngine && metadataService) {
261
- registerDataTools(this.service.toolRegistry, { dataEngine, metadataService });
263
+ if (dataEngine) {
264
+ registerDataTools(this.service.toolRegistry, { dataEngine });
262
265
  ctx.logger.info('[AI] Built-in data tools registered');
263
266
 
264
- // Register the built-in data_chat agent only if it does not already exist
265
- const agentExists =
266
- typeof metadataService.exists === 'function'
267
- ? await metadataService.exists('agent', DATA_CHAT_AGENT.name)
268
- : false;
269
-
270
- if (!agentExists) {
271
- await metadataService.register('agent', DATA_CHAT_AGENT.name, DATA_CHAT_AGENT);
272
- ctx.logger.info('[AI] data_chat agent registered');
273
- } else {
274
- ctx.logger.debug('[AI] data_chat agent already exists, skipping auto-registration');
267
+ // Register data tools as metadata (for Studio visibility)
268
+ if (metadataService) {
269
+ const { DATA_TOOL_DEFINITIONS } = await import('./tools/data-tools.js');
270
+ for (const toolDef of DATA_TOOL_DEFINITIONS) {
271
+ const toolExists =
272
+ typeof metadataService.exists === 'function'
273
+ ? await metadataService.exists('tool', toolDef.name)
274
+ : false;
275
+
276
+ if (!toolExists) {
277
+ await metadataService.register('tool', toolDef.name, toolDef);
278
+ }
279
+ }
280
+ ctx.logger.info(`[AI] ${DATA_TOOL_DEFINITIONS.length} data tools registered as metadata`);
281
+ }
282
+
283
+ // Register the built-in data_chat agent (requires metadata service)
284
+ if (metadataService) {
285
+ try {
286
+ const agentExists =
287
+ typeof metadataService.exists === 'function'
288
+ ? await metadataService.exists('agent', DATA_CHAT_AGENT.name)
289
+ : false;
290
+
291
+ if (!agentExists) {
292
+ await metadataService.register('agent', DATA_CHAT_AGENT.name, DATA_CHAT_AGENT);
293
+ console.log('[AI] Registered data_chat agent to metadataService');
294
+ ctx.logger.info('[AI] data_chat agent registered');
295
+ } else {
296
+ console.log('[AI] data_chat agent already exists, skipping');
297
+ ctx.logger.debug('[AI] data_chat agent already exists, skipping auto-registration');
298
+ }
299
+ } catch (err) {
300
+ ctx.logger.warn('[AI] Failed to register data_chat agent', err instanceof Error ? { error: err.message, stack: err.stack } : { error: String(err) });
301
+ }
275
302
  }
276
303
  }
277
304
  } catch {
@@ -284,17 +311,37 @@ export class AIServicePlugin implements Plugin {
284
311
  registerMetadataTools(this.service.toolRegistry, { metadataService });
285
312
  ctx.logger.info('[AI] Built-in metadata tools registered');
286
313
 
314
+ // Register metadata tools as metadata (for Studio visibility)
315
+ const { METADATA_TOOL_DEFINITIONS } = await import('./tools/metadata-tools.js');
316
+ for (const toolDef of METADATA_TOOL_DEFINITIONS) {
317
+ const toolExists =
318
+ typeof metadataService.exists === 'function'
319
+ ? await metadataService.exists('tool', toolDef.name)
320
+ : false;
321
+
322
+ if (!toolExists) {
323
+ await metadataService.register('tool', toolDef.name, toolDef);
324
+ }
325
+ }
326
+ ctx.logger.info(`[AI] ${METADATA_TOOL_DEFINITIONS.length} metadata tools registered as metadata`);
327
+
287
328
  // Register the built-in metadata_assistant agent
288
- const agentExists =
289
- typeof metadataService.exists === 'function'
290
- ? await metadataService.exists('agent', METADATA_ASSISTANT_AGENT.name)
291
- : false;
292
-
293
- if (!agentExists) {
294
- await metadataService.register('agent', METADATA_ASSISTANT_AGENT.name, METADATA_ASSISTANT_AGENT);
295
- ctx.logger.info('[AI] metadata_assistant agent registered');
296
- } else {
297
- ctx.logger.debug('[AI] metadata_assistant agent already exists, skipping auto-registration');
329
+ try {
330
+ const agentExists =
331
+ typeof metadataService.exists === 'function'
332
+ ? await metadataService.exists('agent', METADATA_ASSISTANT_AGENT.name)
333
+ : false;
334
+
335
+ if (!agentExists) {
336
+ await metadataService.register('agent', METADATA_ASSISTANT_AGENT.name, METADATA_ASSISTANT_AGENT);
337
+ console.log('[AI] Registered metadata_assistant agent to metadataService');
338
+ ctx.logger.info('[AI] metadata_assistant agent registered');
339
+ } else {
340
+ console.log('[AI] metadata_assistant agent already exists, skipping');
341
+ ctx.logger.debug('[AI] metadata_assistant agent already exists, skipping auto-registration');
342
+ }
343
+ } catch (err) {
344
+ ctx.logger.warn('[AI] Failed to register metadata_assistant agent', err instanceof Error ? { error: err.message, stack: err.stack } : { error: String(err) });
298
345
  }
299
346
  } catch (err) {
300
347
  ctx.logger.debug('[AI] Failed to register metadata tools', err instanceof Error ? err : undefined);
@@ -307,15 +354,18 @@ export class AIServicePlugin implements Plugin {
307
354
  // Build and expose route definitions
308
355
  const routes = buildAIRoutes(this.service, this.service.conversationService, ctx.logger);
309
356
 
357
+ // Build tool routes
358
+ const toolRoutes = buildToolRoutes(this.service, ctx.logger);
359
+ routes.push(...toolRoutes);
360
+ ctx.logger.info(`[AI] Tool routes registered (${toolRoutes.length} routes)`);
361
+
310
362
  // Build agent routes if metadata service is available
311
- try {
312
- const metadataService = ctx.getService<IMetadataService>('metadata');
313
- if (metadataService) {
314
- const agentRuntime = new AgentRuntime(metadataService);
315
- const agentRoutes = buildAgentRoutes(this.service, agentRuntime, ctx.logger);
316
- routes.push(...agentRoutes);
317
- }
318
- } catch {
363
+ if (metadataService) {
364
+ const agentRuntime = new AgentRuntime(metadataService);
365
+ const agentRoutes = buildAgentRoutes(this.service, agentRuntime, ctx.logger);
366
+ routes.push(...agentRoutes);
367
+ ctx.logger.info(`[AI] Agent routes registered (${agentRoutes.length} routes)`);
368
+ } else {
319
369
  ctx.logger.debug('[AI] Metadata service not available, skipping agent routes');
320
370
  }
321
371
 
@@ -5,6 +5,8 @@ import type { Logger } from '@objectstack/spec/contracts';
5
5
  import type { AIService } from '../ai-service.js';
6
6
  import type { AgentRuntime, AgentChatContext } from '../agent-runtime.js';
7
7
  import type { RouteDefinition } from './ai-routes.js';
8
+ import { normalizeMessage, validateMessageContent } from './message-utils.js';
9
+ import { encodeVercelDataStream } from '../stream/vercel-stream-encoder.js';
8
10
 
9
11
  /**
10
12
  * Allowed message roles for the agent chat endpoint.
@@ -25,10 +27,10 @@ function validateAgentMessage(raw: unknown): string | null {
25
27
  if (typeof msg.role !== 'string' || !ALLOWED_AGENT_ROLES.has(msg.role)) {
26
28
  return `message.role must be one of ${[...ALLOWED_AGENT_ROLES].map(r => `"${r}"`).join(', ')} for agent chat`;
27
29
  }
28
- if (typeof msg.content !== 'string') {
29
- return 'message.content must be a string';
30
- }
31
- return null;
30
+
31
+ // Assistant messages may legitimately have empty content (e.g. tool-call-only)
32
+ const allowEmpty = msg.role === 'assistant';
33
+ return validateMessageContent(msg, { allowEmptyContent: allowEmpty });
32
34
  }
33
35
 
34
36
  /**
@@ -67,10 +69,15 @@ export function buildAgentRoutes(
67
69
  },
68
70
 
69
71
  // ── Chat with a specific agent ──────────────────────────────
72
+ //
73
+ // Dual-mode endpoint matching the general chat route behaviour:
74
+ // • `stream !== false` → Vercel Data Stream Protocol (SSE)
75
+ // • `stream === false` → JSON response (legacy)
76
+ //
70
77
  {
71
78
  method: 'POST',
72
79
  path: '/api/v1/ai/agents/:agentName/chat',
73
- description: 'Chat with a specific AI agent',
80
+ description: 'Chat with a specific AI agent (supports Vercel AI Data Stream Protocol)',
74
81
  auth: true,
75
82
  permissions: ['ai:chat', 'ai:agents'],
76
83
  handler: async (req) => {
@@ -80,11 +87,12 @@ export function buildAgentRoutes(
80
87
  }
81
88
 
82
89
  // Parse request body
90
+ const body = (req.body ?? {}) as Record<string, unknown>;
83
91
  const {
84
92
  messages: rawMessages,
85
93
  context: chatContext,
86
94
  options: extraOptions,
87
- } = (req.body ?? {}) as {
95
+ } = body as {
88
96
  messages?: unknown[];
89
97
  context?: AgentChatContext;
90
98
  options?: Record<string, unknown>;
@@ -134,15 +142,40 @@ export function buildAgentRoutes(
134
142
  // Prepend system messages then user conversation
135
143
  const fullMessages: ModelMessage[] = [
136
144
  ...systemMessages,
137
- ...(rawMessages as ModelMessage[]),
145
+ ...rawMessages.map(m => normalizeMessage(m as Record<string, unknown>)),
138
146
  ];
139
147
 
140
- // Use chatWithTools for automatic tool resolution
141
- const result = await aiService.chatWithTools(fullMessages, {
148
+ const chatWithToolsOptions = {
142
149
  ...mergedOptions,
143
150
  maxIterations: agent.planning?.maxIterations,
144
- });
151
+ };
152
+
153
+ // ── Choose response mode ─────────────────────────────
154
+ const wantStream = body.stream !== false;
155
+
156
+ if (wantStream) {
157
+ // Vercel Data Stream Protocol (SSE) — matches general chat behaviour
158
+ if (!aiService.streamChatWithTools) {
159
+ return { status: 501, body: { error: 'Streaming is not supported by the configured AI service' } };
160
+ }
161
+ const events = aiService.streamChatWithTools(fullMessages, chatWithToolsOptions);
162
+ return {
163
+ status: 200,
164
+ stream: true,
165
+ vercelDataStream: true,
166
+ contentType: 'text/event-stream',
167
+ headers: {
168
+ 'Content-Type': 'text/event-stream',
169
+ 'Cache-Control': 'no-cache',
170
+ 'Connection': 'keep-alive',
171
+ 'x-vercel-ai-ui-message-stream': 'v1',
172
+ },
173
+ events: encodeVercelDataStream(events),
174
+ };
175
+ }
145
176
 
177
+ // JSON response (non-streaming / legacy)
178
+ const result = await aiService.chatWithTools(fullMessages, chatWithToolsOptions);
146
179
  return { status: 200, body: result };
147
180
  } catch (err) {
148
181
  logger.error(
@@ -3,6 +3,7 @@
3
3
  import type { IAIService, IAIConversationService, ModelMessage } from '@objectstack/spec/contracts';
4
4
  import type { Logger } from '@objectstack/spec/contracts';
5
5
  import { encodeVercelDataStream } from '../stream/vercel-stream-encoder.js';
6
+ import { normalizeMessage, validateMessageContent } from './message-utils.js';
6
7
 
7
8
  /**
8
9
  * Minimal HTTP handler abstraction so routes stay framework-agnostic.
@@ -77,37 +78,6 @@ export interface RouteResponse {
77
78
  /** Valid message roles accepted by the AI routes. */
78
79
  const VALID_ROLES = new Set<string>(['system', 'user', 'assistant', 'tool']);
79
80
 
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
-
111
81
  /**
112
82
  * Validate that `raw` is a well-formed message.
113
83
  * Returns null on success, or an error string on failure.
@@ -125,44 +95,10 @@ function validateMessage(raw: unknown): string | null {
125
95
  if (typeof msg.role !== 'string' || !VALID_ROLES.has(msg.role)) {
126
96
  return `message.role must be one of ${[...VALID_ROLES].map(r => `"${r}"`).join(', ')}`;
127
97
  }
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
- }
157
98
 
158
99
  // 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 });
166
102
  }
167
103
 
168
104
  /**
@@ -2,3 +2,4 @@
2
2
 
3
3
  export { buildAIRoutes } from './ai-routes.js';
4
4
  export type { RouteDefinition, RouteRequest, RouteResponse, RouteUserContext } from './ai-routes.js';
5
+ export { normalizeMessage, validateMessageContent } from './message-utils.js';