@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.
- package/.turbo/turbo-build.log +11 -11
- package/CHANGELOG.md +20 -0
- package/dist/index.cjs +1245 -54
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +344 -77
- package/dist/index.d.ts +344 -77
- package/dist/index.js +1230 -51
- package/dist/index.js.map +1 -1
- package/package.json +26 -4
- package/src/__tests__/ai-service.test.ts +248 -27
- package/src/__tests__/auth-and-toolcalling.test.ts +627 -0
- package/src/__tests__/chatbot-features.test.ts +229 -82
- package/src/__tests__/metadata-tools.test.ts +964 -0
- package/src/__tests__/objectql-conversation-service.test.ts +34 -16
- package/src/__tests__/vercel-stream-encoder.test.ts +263 -0
- package/src/adapters/index.ts +2 -0
- package/src/adapters/memory-adapter.ts +17 -9
- package/src/adapters/vercel-adapter.ts +148 -0
- package/src/agent-runtime.ts +27 -3
- package/src/agents/index.ts +1 -0
- package/src/agents/metadata-assistant-agent.ts +87 -0
- package/src/ai-service.ts +174 -22
- package/src/conversation/in-memory-conversation-service.ts +2 -2
- package/src/conversation/objectql-conversation-service.ts +67 -18
- package/src/index.ts +22 -3
- package/src/plugin.ts +166 -9
- package/src/routes/agent-routes.ts +28 -3
- package/src/routes/ai-routes.ts +231 -14
- package/src/routes/index.ts +1 -1
- package/src/stream/index.ts +3 -0
- package/src/stream/vercel-stream-encoder.ts +129 -0
- package/src/tools/add-field.tool.ts +70 -0
- package/src/tools/create-object.tool.ts +66 -0
- package/src/tools/delete-field.tool.ts +38 -0
- package/src/tools/describe-metadata-object.tool.ts +32 -0
- package/src/tools/index.ts +12 -1
- package/src/tools/list-metadata-objects.tool.ts +34 -0
- package/src/tools/metadata-tools.ts +430 -0
- package/src/tools/modify-field.tool.ts +44 -0
- 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
|
|
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
|
|
106
|
-
ctx.
|
|
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
|
|
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
|
-
|
|
150
|
-
|
|
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 {
|
|
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:
|
|
135
|
+
const fullMessages: ModelMessage[] = [
|
|
111
136
|
...systemMessages,
|
|
112
|
-
...(rawMessages as
|
|
137
|
+
...(rawMessages as ModelMessage[]),
|
|
113
138
|
];
|
|
114
139
|
|
|
115
140
|
// Use chatWithTools for automatic tool resolution
|
package/src/routes/ai-routes.ts
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
|
|
2
2
|
|
|
3
|
-
import type { IAIService, IAIConversationService,
|
|
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
|
-
*
|
|
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
|
-
|
|
62
|
-
|
|
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
|
-
|
|
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: '
|
|
206
|
+
description: 'Chat completion (supports Vercel AI Data Stream Protocol)',
|
|
207
|
+
auth: true,
|
|
208
|
+
permissions: ['ai:chat'],
|
|
97
209
|
handler: async (req) => {
|
|
98
|
-
const
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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) {
|