@objectstack/service-ai 4.0.0 → 4.0.2

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.
Files changed (40) hide show
  1. package/.turbo/turbo-build.log +11 -11
  2. package/CHANGELOG.md +20 -0
  3. package/dist/index.cjs +1245 -54
  4. package/dist/index.cjs.map +1 -1
  5. package/dist/index.d.cts +344 -77
  6. package/dist/index.d.ts +344 -77
  7. package/dist/index.js +1230 -51
  8. package/dist/index.js.map +1 -1
  9. package/package.json +26 -4
  10. package/src/__tests__/ai-service.test.ts +248 -27
  11. package/src/__tests__/auth-and-toolcalling.test.ts +627 -0
  12. package/src/__tests__/chatbot-features.test.ts +229 -82
  13. package/src/__tests__/metadata-tools.test.ts +964 -0
  14. package/src/__tests__/objectql-conversation-service.test.ts +34 -16
  15. package/src/__tests__/vercel-stream-encoder.test.ts +263 -0
  16. package/src/adapters/index.ts +2 -0
  17. package/src/adapters/memory-adapter.ts +17 -9
  18. package/src/adapters/vercel-adapter.ts +148 -0
  19. package/src/agent-runtime.ts +27 -3
  20. package/src/agents/index.ts +1 -0
  21. package/src/agents/metadata-assistant-agent.ts +87 -0
  22. package/src/ai-service.ts +174 -22
  23. package/src/conversation/in-memory-conversation-service.ts +2 -2
  24. package/src/conversation/objectql-conversation-service.ts +67 -18
  25. package/src/index.ts +22 -3
  26. package/src/plugin.ts +166 -9
  27. package/src/routes/agent-routes.ts +28 -3
  28. package/src/routes/ai-routes.ts +231 -14
  29. package/src/routes/index.ts +1 -1
  30. package/src/stream/index.ts +3 -0
  31. package/src/stream/vercel-stream-encoder.ts +129 -0
  32. package/src/tools/add-field.tool.ts +70 -0
  33. package/src/tools/create-object.tool.ts +66 -0
  34. package/src/tools/delete-field.tool.ts +38 -0
  35. package/src/tools/describe-metadata-object.tool.ts +32 -0
  36. package/src/tools/index.ts +12 -1
  37. package/src/tools/list-metadata-objects.tool.ts +34 -0
  38. package/src/tools/metadata-tools.ts +430 -0
  39. package/src/tools/modify-field.tool.ts +44 -0
  40. package/src/tools/tool-registry.ts +32 -9
package/src/plugin.ts CHANGED
@@ -9,8 +9,11 @@ import { buildAgentRoutes } from './routes/agent-routes.js';
9
9
  import { ObjectQLConversationService } from './conversation/objectql-conversation-service.js';
10
10
  import { AiConversationObject, AiMessageObject } from './objects/index.js';
11
11
  import { registerDataTools } from './tools/data-tools.js';
12
+ import { registerMetadataTools } from './tools/metadata-tools.js';
12
13
  import { AgentRuntime } from './agent-runtime.js';
13
- import { DATA_CHAT_AGENT } from './agents/index.js';
14
+ import { DATA_CHAT_AGENT, METADATA_ASSISTANT_AGENT } from './agents/index.js';
15
+ import { VercelLLMAdapter } from './adapters/vercel-adapter.js';
16
+ import { MemoryLLMAdapter } from './adapters/memory-adapter.js';
14
17
 
15
18
  /**
16
19
  * Configuration options for the AIServicePlugin.
@@ -51,7 +54,7 @@ export class AIServicePlugin implements Plugin {
51
54
  name = 'com.objectstack.service-ai';
52
55
  version = '1.0.0';
53
56
  type = 'standard' as const;
54
- dependencies: string[] = [];
57
+ dependencies: string[] = ['com.objectstack.engine.objectql']; // manifest service required
55
58
 
56
59
  private service?: AIService;
57
60
  private readonly options: AIServicePluginOptions;
@@ -60,6 +63,90 @@ export class AIServicePlugin implements Plugin {
60
63
  this.options = options;
61
64
  }
62
65
 
66
+ /**
67
+ * Auto-detect LLM provider from environment variables.
68
+ *
69
+ * Priority order:
70
+ * 1. AI_GATEWAY_MODEL → Vercel AI Gateway
71
+ * 2. OPENAI_API_KEY → OpenAI
72
+ * 3. ANTHROPIC_API_KEY → Anthropic
73
+ * 4. GOOGLE_GENERATIVE_AI_API_KEY → Google
74
+ * 5. Fallback → MemoryLLMAdapter
75
+ *
76
+ * Returns the adapter and a description for logging.
77
+ */
78
+ private async detectAdapter(ctx: PluginContext): Promise<{ adapter: LLMAdapter; description: string }> {
79
+ // 1. Vercel AI Gateway — works with any provider via gateway('provider/model')
80
+ const gatewayModel = process.env.AI_GATEWAY_MODEL;
81
+ if (gatewayModel) {
82
+ try {
83
+ const gatewayPkg = '@ai-sdk/gateway';
84
+ const { gateway } = await import(/* webpackIgnore: true */ gatewayPkg);
85
+ const adapter = new VercelLLMAdapter({ model: gateway(gatewayModel) });
86
+ return { adapter, description: `Vercel AI Gateway (model: ${gatewayModel})` };
87
+ } catch (err) {
88
+ ctx.logger.warn(
89
+ `[AI] Failed to load @ai-sdk/gateway for AI_GATEWAY_MODEL=${gatewayModel}, trying next provider`,
90
+ err instanceof Error ? { error: err.message } : undefined
91
+ );
92
+ }
93
+ }
94
+
95
+ // 2. Direct provider SDKs
96
+ const providerConfigs: Array<{
97
+ envKey: string;
98
+ pkg: string;
99
+ factory: string;
100
+ defaultModel: string;
101
+ displayName: string;
102
+ }> = [
103
+ {
104
+ envKey: 'OPENAI_API_KEY',
105
+ pkg: '@ai-sdk/openai',
106
+ factory: 'openai',
107
+ defaultModel: 'gpt-4o',
108
+ displayName: 'OpenAI'
109
+ },
110
+ {
111
+ envKey: 'ANTHROPIC_API_KEY',
112
+ pkg: '@ai-sdk/anthropic',
113
+ factory: 'anthropic',
114
+ defaultModel: 'claude-sonnet-4-20250514',
115
+ displayName: 'Anthropic'
116
+ },
117
+ {
118
+ envKey: 'GOOGLE_GENERATIVE_AI_API_KEY',
119
+ pkg: '@ai-sdk/google',
120
+ factory: 'google',
121
+ defaultModel: 'gemini-2.0-flash',
122
+ displayName: 'Google'
123
+ },
124
+ ];
125
+
126
+ for (const { envKey, pkg, factory, defaultModel, displayName } of providerConfigs) {
127
+ if (process.env[envKey]) {
128
+ try {
129
+ const mod = await import(/* webpackIgnore: true */ pkg);
130
+ const createModel = mod[factory] ?? mod.default;
131
+ if (typeof createModel === 'function') {
132
+ const modelId = process.env.AI_MODEL ?? defaultModel;
133
+ const adapter = new VercelLLMAdapter({ model: createModel(modelId) });
134
+ return { adapter, description: `${displayName} (model: ${modelId})` };
135
+ }
136
+ } catch (err) {
137
+ ctx.logger.warn(
138
+ `[AI] Failed to load ${pkg} for ${envKey}, trying next provider`,
139
+ err instanceof Error ? { error: err.message } : undefined
140
+ );
141
+ }
142
+ }
143
+ }
144
+
145
+ // 3. Fallback to MemoryLLMAdapter
146
+ ctx.logger.warn('[AI] No LLM provider configured via environment variables. Falling back to MemoryLLMAdapter (echo mode). Set AI_GATEWAY_MODEL, OPENAI_API_KEY, ANTHROPIC_API_KEY, or GOOGLE_GENERATIVE_AI_API_KEY to use a real LLM.');
147
+ return { adapter: new MemoryLLMAdapter(), description: 'MemoryLLMAdapter (echo mode - for testing only)' };
148
+ }
149
+
63
150
  async init(ctx: PluginContext): Promise<void> {
64
151
  // Check if there is an existing AI service (e.g. from dev-plugin)
65
152
  let hasExisting = false;
@@ -87,8 +174,26 @@ export class AIServicePlugin implements Plugin {
87
174
  }
88
175
  }
89
176
 
177
+ // Determine LLM adapter: explicit > auto-detect from env > MemoryLLMAdapter fallback
178
+ let adapter: LLMAdapter;
179
+ let adapterDescription: string;
180
+
181
+ if (this.options.adapter) {
182
+ // User provided an explicit adapter
183
+ adapter = this.options.adapter;
184
+ adapterDescription = `${adapter.name} (explicitly configured)`;
185
+ } else {
186
+ // Auto-detect from environment variables
187
+ const detected = await this.detectAdapter(ctx);
188
+ adapter = detected.adapter;
189
+ adapterDescription = detected.description;
190
+ }
191
+
192
+ // Log the selected adapter
193
+ ctx.logger.info(`[AI] Using LLM adapter: ${adapterDescription}`);
194
+
90
195
  const config: AIServiceConfig = {
91
- adapter: this.options.adapter,
196
+ adapter,
92
197
  logger: ctx.logger,
93
198
  conversationService,
94
199
  };
@@ -102,8 +207,8 @@ export class AIServicePlugin implements Plugin {
102
207
  ctx.registerService('ai', this.service);
103
208
  }
104
209
 
105
- // Register AI system objects so ObjectQLPlugin auto-discovers them
106
- ctx.registerService('app.com.objectstack.service-ai', {
210
+ // Register AI system objects via the manifest service.
211
+ ctx.getService<{ register(m: any): void }>('manifest').register({
107
212
  id: 'com.objectstack.service-ai',
108
213
  name: 'AI Service',
109
214
  version: '1.0.0',
@@ -118,16 +223,40 @@ export class AIServicePlugin implements Plugin {
118
223
  });
119
224
  }
120
225
 
226
+ // Contribute navigation items to the Setup App (if SetupPlugin is loaded).
227
+ try {
228
+ const setupNav = ctx.getService<{ contribute(c: any): void }>('setupNav');
229
+ if (setupNav) {
230
+ setupNav.contribute({
231
+ areaId: 'area_ai',
232
+ 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 },
235
+ ],
236
+ });
237
+ ctx.logger.info('[AI] Navigation items contributed to Setup App');
238
+ }
239
+ } catch {
240
+ // SetupPlugin not loaded — skip silently
241
+ }
242
+
121
243
  ctx.logger.info('[AI] Service initialized');
122
244
  }
123
245
 
124
246
  async start(ctx: PluginContext): Promise<void> {
125
247
  if (!this.service) return;
126
248
 
127
- // ── Auto-register built-in data tools if data engine + metadata are available ──
249
+ // ── Auto-register built-in tools & agents when services are available ──
250
+ let metadataService: IMetadataService | undefined;
251
+ try {
252
+ metadataService = ctx.getService<IMetadataService>('metadata');
253
+ } catch {
254
+ ctx.logger.debug('[AI] Metadata service not available');
255
+ }
256
+
257
+ // Data tools require both data engine and metadata service
128
258
  try {
129
259
  const dataEngine = ctx.getService<IDataEngine>('data');
130
- const metadataService = ctx.getService<IMetadataService>('metadata');
131
260
  if (dataEngine && metadataService) {
132
261
  registerDataTools(this.service.toolRegistry, { dataEngine, metadataService });
133
262
  ctx.logger.info('[AI] Built-in data tools registered');
@@ -146,8 +275,30 @@ export class AIServicePlugin implements Plugin {
146
275
  }
147
276
  }
148
277
  } catch {
149
- // Data engine or metadata service not available skip data tools
150
- ctx.logger.debug('[AI] Data engine or metadata service not available, skipping data tools');
278
+ ctx.logger.debug('[AI] Data engine not available, skipping data tools');
279
+ }
280
+
281
+ // Metadata tools require only the metadata service
282
+ if (metadataService) {
283
+ try {
284
+ registerMetadataTools(this.service.toolRegistry, { metadataService });
285
+ ctx.logger.info('[AI] Built-in metadata tools registered');
286
+
287
+ // 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');
298
+ }
299
+ } catch (err) {
300
+ ctx.logger.debug('[AI] Failed to register metadata tools', err instanceof Error ? err : undefined);
301
+ }
151
302
  }
152
303
 
153
304
  // Trigger hook to notify AI service is ready — other plugins can register tools
@@ -171,6 +322,12 @@ export class AIServicePlugin implements Plugin {
171
322
  // Trigger hook so HTTP server plugins can mount these routes
172
323
  await ctx.trigger('ai:routes', routes);
173
324
 
325
+ // Cache routes on the kernel so HttpDispatcher can find them
326
+ const kernel = ctx.getKernel();
327
+ if (kernel) {
328
+ (kernel as any).__aiRoutes = routes;
329
+ }
330
+
174
331
  ctx.logger.info(
175
332
  `[AI] Service started — adapter="${this.service.adapterName}", ` +
176
333
  `tools=${this.service.toolRegistry.size}, ` +
@@ -1,6 +1,6 @@
1
1
  // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2
2
 
3
- import type { AIMessage } from '@objectstack/spec/contracts';
3
+ import type { ModelMessage } from '@objectstack/spec/contracts';
4
4
  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';
@@ -36,6 +36,7 @@ function validateAgentMessage(raw: unknown): string | null {
36
36
  *
37
37
  * | Method | Path | Description |
38
38
  * |:---|:---|:---|
39
+ * | GET | /api/v1/ai/agents | List all active agents |
39
40
  * | POST | /api/v1/ai/agents/:agentName/chat | Chat with a specific agent |
40
41
  */
41
42
  export function buildAgentRoutes(
@@ -44,10 +45,34 @@ export function buildAgentRoutes(
44
45
  logger: Logger,
45
46
  ): RouteDefinition[] {
46
47
  return [
48
+ // ── List active agents ──────────────────────────────────────
49
+ {
50
+ method: 'GET',
51
+ path: '/api/v1/ai/agents',
52
+ description: 'List all active AI agents',
53
+ auth: true,
54
+ permissions: ['ai:chat'],
55
+ handler: async () => {
56
+ try {
57
+ const agents = await agentRuntime.listAgents();
58
+ return { status: 200, body: { agents } };
59
+ } catch (err) {
60
+ logger.error(
61
+ '[AI Route] /agents list error',
62
+ err instanceof Error ? err : undefined,
63
+ );
64
+ return { status: 500, body: { error: 'Internal AI service error' } };
65
+ }
66
+ },
67
+ },
68
+
69
+ // ── Chat with a specific agent ──────────────────────────────
47
70
  {
48
71
  method: 'POST',
49
72
  path: '/api/v1/ai/agents/:agentName/chat',
50
73
  description: 'Chat with a specific AI agent',
74
+ auth: true,
75
+ permissions: ['ai:chat', 'ai:agents'],
51
76
  handler: async (req) => {
52
77
  const agentName = req.params?.agentName;
53
78
  if (!agentName) {
@@ -107,9 +132,9 @@ export function buildAgentRoutes(
107
132
  const mergedOptions = { ...agentOptions, ...safeOverrides };
108
133
 
109
134
  // Prepend system messages then user conversation
110
- const fullMessages: AIMessage[] = [
135
+ const fullMessages: ModelMessage[] = [
111
136
  ...systemMessages,
112
- ...(rawMessages as AIMessage[]),
137
+ ...(rawMessages as ModelMessage[]),
113
138
  ];
114
139
 
115
140
  // Use chatWithTools for automatic tool resolution
@@ -1,7 +1,8 @@
1
1
  // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2
2
 
3
- import type { IAIService, IAIConversationService, AIMessage } from '@objectstack/spec/contracts';
3
+ import type { IAIService, IAIConversationService, ModelMessage } from '@objectstack/spec/contracts';
4
4
  import type { Logger } from '@objectstack/spec/contracts';
5
+ import { encodeVercelDataStream } from '../stream/vercel-stream-encoder.js';
5
6
 
6
7
  /**
7
8
  * Minimal HTTP handler abstraction so routes stay framework-agnostic.
@@ -16,6 +17,10 @@ export interface RouteDefinition {
16
17
  path: string;
17
18
  /** Human-readable description */
18
19
  description: string;
20
+ /** Whether this route requires authentication (default: true). */
21
+ auth?: boolean;
22
+ /** Required permissions for accessing this route. */
23
+ permissions?: string[];
19
24
  /**
20
25
  * Handler receives a plain request-like object and returns a response-like
21
26
  * object. SSE responses set `stream: true` and provide an async iterable.
@@ -23,6 +28,22 @@ export interface RouteDefinition {
23
28
  handler: (req: RouteRequest) => Promise<RouteResponse>;
24
29
  }
25
30
 
31
+ /**
32
+ * Authenticated user context attached to a route request.
33
+ *
34
+ * Populated by the auth middleware when `RouteDefinition.auth` is `true`.
35
+ */
36
+ export interface RouteUserContext {
37
+ /** Unique user identifier. */
38
+ userId: string;
39
+ /** User display name (optional). */
40
+ displayName?: string;
41
+ /** Roles assigned to the user (e.g. `['admin', 'user']`). */
42
+ roles?: string[];
43
+ /** Fine-grained permissions (e.g. `['ai:chat', 'ai:admin']`). */
44
+ permissions?: string[];
45
+ }
46
+
26
47
  export interface RouteRequest {
27
48
  /** Parsed JSON body (for POST requests) */
28
49
  body?: unknown;
@@ -30,6 +51,8 @@ export interface RouteRequest {
30
51
  params?: Record<string, string>;
31
52
  /** Query string parameters */
32
53
  query?: Record<string, string>;
54
+ /** Authenticated user context (populated by auth middleware). */
55
+ user?: RouteUserContext;
33
56
  }
34
57
 
35
58
  export interface RouteResponse {
@@ -41,14 +64,58 @@ export interface RouteResponse {
41
64
  stream?: boolean;
42
65
  /** Async iterable of SSE events (when stream=true) */
43
66
  events?: AsyncIterable<unknown>;
67
+ /**
68
+ * When `true`, the HTTP server layer should encode the `events` iterable
69
+ * using the Vercel AI Data Stream Protocol frame format (`0:`, `9:`, `d:`, …)
70
+ * instead of generic SSE `data:` lines.
71
+ *
72
+ * @see https://ai-sdk.dev/docs/ai-sdk-ui/stream-protocol
73
+ */
74
+ vercelDataStream?: boolean;
44
75
  }
45
76
 
46
77
  /** Valid message roles accepted by the AI routes. */
47
78
  const VALID_ROLES = new Set<string>(['system', 'user', 'assistant', 'tool']);
48
79
 
49
80
  /**
50
- * Validate that `raw` is a well-formed AIMessage.
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
+ /**
112
+ * Validate that `raw` is a well-formed message.
51
113
  * Returns null on success, or an error string on failure.
114
+ *
115
+ * Accepts:
116
+ * - Simple string `content` (legacy)
117
+ * - Array `content` (e.g. `[{ type: 'text', text: '...' }]`)
118
+ * - Vercel AI SDK v6 `parts` format (content may be absent/null)
52
119
  */
53
120
  function validateMessage(raw: unknown): string | null {
54
121
  if (typeof raw !== 'object' || raw === null) {
@@ -58,10 +125,44 @@ function validateMessage(raw: unknown): string | null {
58
125
  if (typeof msg.role !== 'string' || !VALID_ROLES.has(msg.role)) {
59
126
  return `message.role must be one of ${[...VALID_ROLES].map(r => `"${r}"`).join(', ')}`;
60
127
  }
61
- if (typeof msg.content !== 'string') {
62
- return 'message.content must be a string';
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;
63
134
  }
64
- return null;
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
+
158
+ // 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';
65
166
  }
66
167
 
67
168
  /**
@@ -90,16 +191,26 @@ export function buildAIRoutes(
90
191
  ): RouteDefinition[] {
91
192
  return [
92
193
  // ── Chat ────────────────────────────────────────────────────
194
+ //
195
+ // Dual-mode endpoint compatible with both the legacy ObjectStack
196
+ // format (`{ messages, options }`) and the Vercel AI SDK useChat
197
+ // flat format (`{ messages, system, model, stream, … }`).
198
+ //
199
+ // Behaviour:
200
+ // • `stream !== false` → Vercel Data Stream Protocol (SSE)
201
+ // • `stream === false` → JSON response (legacy)
202
+ //
93
203
  {
94
204
  method: 'POST',
95
205
  path: '/api/v1/ai/chat',
96
- description: 'Synchronous chat completion',
206
+ description: 'Chat completion (supports Vercel AI Data Stream Protocol)',
207
+ auth: true,
208
+ permissions: ['ai:chat'],
97
209
  handler: async (req) => {
98
- const { messages, options } = (req.body ?? {}) as {
99
- messages?: unknown[];
100
- options?: Record<string, unknown>;
101
- };
210
+ const body = (req.body ?? {}) as Record<string, unknown>;
102
211
 
212
+ // ── Parse messages ───────────────────────────────────
213
+ const messages = body.messages as unknown[] | undefined;
103
214
  if (!Array.isArray(messages) || messages.length === 0) {
104
215
  return { status: 400, body: { error: 'messages array is required' } };
105
216
  }
@@ -109,8 +220,64 @@ export function buildAIRoutes(
109
220
  if (err) return { status: 400, body: { error: err } };
110
221
  }
111
222
 
223
+ // ── Resolve options ──────────────────────────────────
224
+ // Accept legacy nested `options` object **or** Vercel-style
225
+ // flat fields (`model`, `temperature`, `maxTokens`).
226
+ const nested = (body.options ?? {}) as Record<string, unknown>;
227
+ const resolvedOptions: Record<string, unknown> = {
228
+ ...nested,
229
+ ...(body.model != null && { model: body.model }),
230
+ ...(body.temperature != null && { temperature: body.temperature }),
231
+ ...(body.maxTokens != null && { maxTokens: body.maxTokens }),
232
+ };
233
+
234
+ // ── Prepend system prompt ────────────────────────────
235
+ // Vercel useChat sends `system` (or the deprecated `systemPrompt`)
236
+ // as a top-level field. We prepend it as a system message.
237
+ const rawSystemPrompt = body.system ?? body.systemPrompt;
238
+ if (rawSystemPrompt != null && typeof rawSystemPrompt !== 'string') {
239
+ return { status: 400, body: { error: 'system/systemPrompt must be a string' } };
240
+ }
241
+ const systemPrompt = rawSystemPrompt as string | undefined;
242
+ const finalMessages: ModelMessage[] = [
243
+ ...(systemPrompt
244
+ ? [{ role: 'system' as const, content: systemPrompt }]
245
+ : []),
246
+ ...messages.map(m => normalizeMessage(m as Record<string, unknown>)),
247
+ ];
248
+
249
+ // ── Choose response mode ─────────────────────────────
250
+ const wantStream = body.stream !== false;
251
+
252
+ if (wantStream) {
253
+ // UI Message Stream Protocol (SSE with JSON payloads)
254
+ try {
255
+ if (!(aiService as any).streamChatWithTools) {
256
+ return { status: 501, body: { error: 'Streaming is not supported by the configured AI service' } };
257
+ }
258
+ const events = (aiService as any).streamChatWithTools(finalMessages, resolvedOptions as any);
259
+ return {
260
+ status: 200,
261
+ stream: true,
262
+ vercelDataStream: true,
263
+ contentType: 'text/event-stream',
264
+ headers: {
265
+ 'Content-Type': 'text/event-stream',
266
+ 'Cache-Control': 'no-cache',
267
+ 'Connection': 'keep-alive',
268
+ 'x-vercel-ai-ui-message-stream': 'v1',
269
+ },
270
+ events: encodeVercelDataStream(events),
271
+ };
272
+ } catch (err) {
273
+ logger.error('[AI Route] /chat stream error', err instanceof Error ? err : undefined);
274
+ return { status: 500, body: { error: 'Internal AI service error' } };
275
+ }
276
+ }
277
+
278
+ // JSON response (non-streaming)
112
279
  try {
113
- const result = await aiService.chat(messages as AIMessage[], options as any);
280
+ const result = await (aiService as any).chatWithTools(finalMessages, resolvedOptions as any);
114
281
  return { status: 200, body: result };
115
282
  } catch (err) {
116
283
  logger.error('[AI Route] /chat error', err instanceof Error ? err : undefined);
@@ -124,6 +291,8 @@ export function buildAIRoutes(
124
291
  method: 'POST',
125
292
  path: '/api/v1/ai/chat/stream',
126
293
  description: 'SSE streaming chat completion',
294
+ auth: true,
295
+ permissions: ['ai:chat'],
127
296
  handler: async (req) => {
128
297
  const { messages, options } = (req.body ?? {}) as {
129
298
  messages?: unknown[];
@@ -143,7 +312,7 @@ export function buildAIRoutes(
143
312
  if (!aiService.streamChat) {
144
313
  return { status: 501, body: { error: 'Streaming is not supported by the configured AI service' } };
145
314
  }
146
- const events = aiService.streamChat(messages as AIMessage[], options as any);
315
+ const events = aiService.streamChat(messages.map(m => normalizeMessage(m as Record<string, unknown>)), options as any);
147
316
  return { status: 200, stream: true, events };
148
317
  } catch (err) {
149
318
  logger.error('[AI Route] /chat/stream error', err instanceof Error ? err : undefined);
@@ -157,6 +326,8 @@ export function buildAIRoutes(
157
326
  method: 'POST',
158
327
  path: '/api/v1/ai/complete',
159
328
  description: 'Text completion',
329
+ auth: true,
330
+ permissions: ['ai:complete'],
160
331
  handler: async (req) => {
161
332
  const { prompt, options } = (req.body ?? {}) as {
162
333
  prompt?: string;
@@ -182,6 +353,8 @@ export function buildAIRoutes(
182
353
  method: 'GET',
183
354
  path: '/api/v1/ai/models',
184
355
  description: 'List available models',
356
+ auth: true,
357
+ permissions: ['ai:read'],
185
358
  handler: async () => {
186
359
  try {
187
360
  const models = aiService.listModels ? await aiService.listModels() : [];
@@ -198,9 +371,20 @@ export function buildAIRoutes(
198
371
  method: 'POST',
199
372
  path: '/api/v1/ai/conversations',
200
373
  description: 'Create a conversation',
374
+ auth: true,
375
+ permissions: ['ai:conversations'],
201
376
  handler: async (req) => {
202
377
  try {
203
- const options = (req.body ?? {}) as Record<string, unknown>;
378
+ // Ensure the request body is a non-null object before mutating it
379
+ if (req.body !== undefined && req.body !== null && (typeof req.body !== 'object' || Array.isArray(req.body))) {
380
+ return { status: 400, body: { error: 'Invalid request payload' } };
381
+ }
382
+
383
+ const options: Record<string, unknown> = { ...((req.body ?? {}) as Record<string, unknown>) };
384
+ // Bind the conversation to the authenticated user
385
+ if (req.user?.userId) {
386
+ options.userId = req.user.userId;
387
+ }
204
388
  const conversation = await conversationService.create(options as any);
205
389
  return { status: 201, body: conversation };
206
390
  } catch (err) {
@@ -213,6 +397,8 @@ export function buildAIRoutes(
213
397
  method: 'GET',
214
398
  path: '/api/v1/ai/conversations',
215
399
  description: 'List conversations',
400
+ auth: true,
401
+ permissions: ['ai:conversations'],
216
402
  handler: async (req) => {
217
403
  try {
218
404
  const rawQuery = req.query ?? {};
@@ -226,6 +412,11 @@ export function buildAIRoutes(
226
412
  options.limit = parsedLimit;
227
413
  }
228
414
 
415
+ // Scope to the authenticated user's conversations
416
+ if (req.user?.userId) {
417
+ options.userId = req.user.userId;
418
+ }
419
+
229
420
  const conversations = await conversationService.list(options as any);
230
421
  return { status: 200, body: { conversations } };
231
422
  } catch (err) {
@@ -238,6 +429,8 @@ export function buildAIRoutes(
238
429
  method: 'POST',
239
430
  path: '/api/v1/ai/conversations/:id/messages',
240
431
  description: 'Add message to a conversation',
432
+ auth: true,
433
+ permissions: ['ai:conversations'],
241
434
  handler: async (req) => {
242
435
  const id = req.params?.id;
243
436
  if (!id) {
@@ -251,7 +444,18 @@ export function buildAIRoutes(
251
444
  }
252
445
 
253
446
  try {
254
- const conversation = await conversationService.addMessage(id, message as AIMessage);
447
+ // Ownership check: verify the conversation belongs to the current user
448
+ if (req.user?.userId) {
449
+ const existing = await conversationService.get(id);
450
+ if (!existing) {
451
+ return { status: 404, body: { error: `Conversation "${id}" not found` } };
452
+ }
453
+ if (existing.userId && existing.userId !== req.user.userId) {
454
+ return { status: 403, body: { error: 'You do not have access to this conversation' } };
455
+ }
456
+ }
457
+
458
+ const conversation = await conversationService.addMessage(id, message as ModelMessage);
255
459
  return { status: 200, body: conversation };
256
460
  } catch (err) {
257
461
  const msg = err instanceof Error ? err.message : String(err);
@@ -267,6 +471,8 @@ export function buildAIRoutes(
267
471
  method: 'DELETE',
268
472
  path: '/api/v1/ai/conversations/:id',
269
473
  description: 'Delete a conversation',
474
+ auth: true,
475
+ permissions: ['ai:conversations'],
270
476
  handler: async (req) => {
271
477
  const id = req.params?.id;
272
478
  if (!id) {
@@ -274,6 +480,17 @@ export function buildAIRoutes(
274
480
  }
275
481
 
276
482
  try {
483
+ // Ownership check: verify the conversation belongs to the current user
484
+ if (req.user?.userId) {
485
+ const existing = await conversationService.get(id);
486
+ if (!existing) {
487
+ return { status: 404, body: { error: `Conversation "${id}" not found` } };
488
+ }
489
+ if (existing.userId && existing.userId !== req.user.userId) {
490
+ return { status: 403, body: { error: 'You do not have access to this conversation' } };
491
+ }
492
+ }
493
+
277
494
  await conversationService.delete(id);
278
495
  return { status: 204 };
279
496
  } catch (err) {