@objectstack/service-ai 4.0.0 → 4.0.1
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 +10 -10
- package/CHANGELOG.md +11 -0
- package/dist/index.cjs +140 -3
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +30 -1
- package/dist/index.d.ts +30 -1
- package/dist/index.js +140 -3
- package/dist/index.js.map +1 -1
- package/package.json +3 -3
- package/src/__tests__/auth-and-toolcalling.test.ts +625 -0
- package/src/ai-service.ts +126 -6
- package/src/index.ts +1 -1
- package/src/routes/agent-routes.ts +2 -0
- package/src/routes/ai-routes.ts +75 -1
- package/src/routes/index.ts +1 -1
package/src/ai-service.ts
CHANGED
|
@@ -131,8 +131,8 @@ export class AIService implements IAIService {
|
|
|
131
131
|
messages: AIMessage[],
|
|
132
132
|
options?: ChatWithToolsOptions,
|
|
133
133
|
): Promise<AIResult> {
|
|
134
|
-
// Destructure
|
|
135
|
-
const { maxIterations: maxIter, ...restOptions } = options ?? {};
|
|
134
|
+
// Destructure loop-specific options so they are never forwarded to the adapter
|
|
135
|
+
const { maxIterations: maxIter, onToolError, ...restOptions } = options ?? {};
|
|
136
136
|
const maxIterations = maxIter ?? AIService.DEFAULT_MAX_ITERATIONS;
|
|
137
137
|
const registeredTools = this.toolRegistry.getAll();
|
|
138
138
|
|
|
@@ -152,12 +152,17 @@ export class AIService implements IAIService {
|
|
|
152
152
|
// Working copy of the conversation
|
|
153
153
|
const conversation = [...messages];
|
|
154
154
|
|
|
155
|
+
// Track errors across iterations for diagnostics
|
|
156
|
+
const toolErrors: Array<{ iteration: number; toolName: string; error: string }> = [];
|
|
157
|
+
|
|
155
158
|
this.logger.debug('[AI] chatWithTools start', {
|
|
156
159
|
messageCount: conversation.length,
|
|
157
160
|
toolCount: mergedTools.length,
|
|
158
161
|
maxIterations,
|
|
159
162
|
});
|
|
160
163
|
|
|
164
|
+
let abortedByCallback = false;
|
|
165
|
+
|
|
161
166
|
for (let iteration = 0; iteration < maxIterations; iteration++) {
|
|
162
167
|
const result = await this.adapter.chat(conversation, chatOptions);
|
|
163
168
|
|
|
@@ -182,19 +187,47 @@ export class AIService implements IAIService {
|
|
|
182
187
|
// Execute all tool calls in parallel
|
|
183
188
|
const toolResults = await this.toolRegistry.executeAll(result.toolCalls);
|
|
184
189
|
|
|
185
|
-
//
|
|
190
|
+
// Process results: track errors and honour onToolError callback
|
|
186
191
|
for (const tr of toolResults) {
|
|
192
|
+
if (tr.isError) {
|
|
193
|
+
// Match tool call by toolCallId for robust attribution
|
|
194
|
+
const matchedCall = result.toolCalls!.find(tc => tc.id === tr.toolCallId);
|
|
195
|
+
const toolName = matchedCall?.name ?? 'unknown';
|
|
196
|
+
const errorEntry = { iteration, toolName, error: tr.content };
|
|
197
|
+
toolErrors.push(errorEntry);
|
|
198
|
+
this.logger.warn('[AI] chatWithTools tool error', errorEntry);
|
|
199
|
+
|
|
200
|
+
if (onToolError && matchedCall) {
|
|
201
|
+
const action = onToolError(matchedCall, tr.content);
|
|
202
|
+
if (action === 'abort') {
|
|
203
|
+
abortedByCallback = true;
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// Append each tool result as a `role: 'tool'` message
|
|
187
209
|
conversation.push({
|
|
188
210
|
role: 'tool',
|
|
189
211
|
content: tr.content,
|
|
190
212
|
toolCallId: tr.toolCallId,
|
|
191
213
|
});
|
|
192
214
|
}
|
|
215
|
+
|
|
216
|
+
if (abortedByCallback) {
|
|
217
|
+
break;
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// Distinguish user-driven abort from max-iterations exhaustion in logs
|
|
222
|
+
if (abortedByCallback) {
|
|
223
|
+
this.logger.warn('[AI] chatWithTools aborted by onToolError callback', { toolErrors });
|
|
224
|
+
} else {
|
|
225
|
+
this.logger.warn('[AI] chatWithTools max iterations reached, forcing final response', {
|
|
226
|
+
toolErrors: toolErrors.length > 0 ? toolErrors : undefined,
|
|
227
|
+
});
|
|
193
228
|
}
|
|
194
229
|
|
|
195
|
-
//
|
|
196
|
-
// call *without* tools so the model is forced to produce text.
|
|
197
|
-
this.logger.warn('[AI] chatWithTools max iterations reached, forcing final response');
|
|
230
|
+
// Make one last call *without* tools so the model is forced to produce text.
|
|
198
231
|
const finalResult = await this.adapter.chat(conversation, {
|
|
199
232
|
...chatOptions,
|
|
200
233
|
tools: undefined,
|
|
@@ -202,4 +235,91 @@ export class AIService implements IAIService {
|
|
|
202
235
|
});
|
|
203
236
|
return finalResult;
|
|
204
237
|
}
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* Stream chat with automatic tool call resolution.
|
|
241
|
+
*
|
|
242
|
+
* Works like {@link chatWithTools} but yields SSE events. When the model
|
|
243
|
+
* requests tool calls during streaming, they are executed and the results
|
|
244
|
+
* fed back until a final text stream is produced.
|
|
245
|
+
*/
|
|
246
|
+
async *streamChatWithTools(
|
|
247
|
+
messages: AIMessage[],
|
|
248
|
+
options?: ChatWithToolsOptions,
|
|
249
|
+
): AsyncIterable<AIStreamEvent> {
|
|
250
|
+
const { maxIterations: maxIter, onToolError, ...restOptions } = options ?? {};
|
|
251
|
+
const maxIterations = maxIter ?? AIService.DEFAULT_MAX_ITERATIONS;
|
|
252
|
+
const registeredTools = this.toolRegistry.getAll();
|
|
253
|
+
|
|
254
|
+
const mergedTools = [
|
|
255
|
+
...registeredTools,
|
|
256
|
+
...(restOptions.tools ?? []),
|
|
257
|
+
];
|
|
258
|
+
|
|
259
|
+
const chatOptions: AIRequestOptions = {
|
|
260
|
+
...restOptions,
|
|
261
|
+
tools: mergedTools.length > 0 ? mergedTools : undefined,
|
|
262
|
+
toolChoice: mergedTools.length > 0 ? (restOptions.toolChoice ?? 'auto') : undefined,
|
|
263
|
+
};
|
|
264
|
+
|
|
265
|
+
const conversation = [...messages];
|
|
266
|
+
let abortedByCallback = false;
|
|
267
|
+
|
|
268
|
+
for (let iteration = 0; iteration < maxIterations; iteration++) {
|
|
269
|
+
// Use non-streaming chat for intermediate tool-call rounds
|
|
270
|
+
const result = await this.adapter.chat(conversation, chatOptions);
|
|
271
|
+
|
|
272
|
+
if (!result.toolCalls || result.toolCalls.length === 0) {
|
|
273
|
+
// Final round — return the probed result without an extra model call
|
|
274
|
+
yield { type: 'text-delta', textDelta: result.content };
|
|
275
|
+
yield { type: 'finish', result };
|
|
276
|
+
return;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
// Emit tool-call events so the client can see tool execution progress
|
|
280
|
+
for (const tc of result.toolCalls) {
|
|
281
|
+
yield { type: 'tool-call', toolCall: tc };
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
conversation.push({
|
|
285
|
+
role: 'assistant',
|
|
286
|
+
content: result.content ?? '',
|
|
287
|
+
toolCalls: result.toolCalls,
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
const toolResults = await this.toolRegistry.executeAll(result.toolCalls);
|
|
291
|
+
|
|
292
|
+
for (const tr of toolResults) {
|
|
293
|
+
if (tr.isError && onToolError) {
|
|
294
|
+
const matchedCall = result.toolCalls!.find(tc => tc.id === tr.toolCallId);
|
|
295
|
+
if (matchedCall) {
|
|
296
|
+
const action = onToolError(matchedCall, tr.content);
|
|
297
|
+
if (action === 'abort') {
|
|
298
|
+
abortedByCallback = true;
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
conversation.push({
|
|
303
|
+
role: 'tool',
|
|
304
|
+
content: tr.content,
|
|
305
|
+
toolCallId: tr.toolCallId,
|
|
306
|
+
});
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
if (abortedByCallback) {
|
|
310
|
+
break;
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
// Forced final response (no tools) — either aborted or max iterations
|
|
315
|
+
if (abortedByCallback) {
|
|
316
|
+
this.logger.warn('[AI] streamChatWithTools aborted by onToolError callback');
|
|
317
|
+
} else {
|
|
318
|
+
this.logger.warn('[AI] streamChatWithTools max iterations reached');
|
|
319
|
+
}
|
|
320
|
+
const finalOptions = { ...chatOptions, tools: undefined, toolChoice: undefined };
|
|
321
|
+
const result = await this.adapter.chat(conversation, finalOptions);
|
|
322
|
+
yield { type: 'text-delta', textDelta: result.content };
|
|
323
|
+
yield { type: 'finish', result };
|
|
324
|
+
}
|
|
205
325
|
}
|
package/src/index.ts
CHANGED
|
@@ -37,4 +37,4 @@ export { AiConversationObject, AiMessageObject } from './objects/index.js';
|
|
|
37
37
|
// Routes
|
|
38
38
|
export { buildAIRoutes } from './routes/ai-routes.js';
|
|
39
39
|
export { buildAgentRoutes } from './routes/agent-routes.js';
|
|
40
|
-
export type { RouteDefinition, RouteRequest, RouteResponse } from './routes/ai-routes.js';
|
|
40
|
+
export type { RouteDefinition, RouteRequest, RouteResponse, RouteUserContext } from './routes/ai-routes.js';
|
|
@@ -48,6 +48,8 @@ export function buildAgentRoutes(
|
|
|
48
48
|
method: 'POST',
|
|
49
49
|
path: '/api/v1/ai/agents/:agentName/chat',
|
|
50
50
|
description: 'Chat with a specific AI agent',
|
|
51
|
+
auth: true,
|
|
52
|
+
permissions: ['ai:chat', 'ai:agents'],
|
|
51
53
|
handler: async (req) => {
|
|
52
54
|
const agentName = req.params?.agentName;
|
|
53
55
|
if (!agentName) {
|
package/src/routes/ai-routes.ts
CHANGED
|
@@ -16,6 +16,10 @@ export interface RouteDefinition {
|
|
|
16
16
|
path: string;
|
|
17
17
|
/** Human-readable description */
|
|
18
18
|
description: string;
|
|
19
|
+
/** Whether this route requires authentication (default: true). */
|
|
20
|
+
auth?: boolean;
|
|
21
|
+
/** Required permissions for accessing this route. */
|
|
22
|
+
permissions?: string[];
|
|
19
23
|
/**
|
|
20
24
|
* Handler receives a plain request-like object and returns a response-like
|
|
21
25
|
* object. SSE responses set `stream: true` and provide an async iterable.
|
|
@@ -23,6 +27,22 @@ export interface RouteDefinition {
|
|
|
23
27
|
handler: (req: RouteRequest) => Promise<RouteResponse>;
|
|
24
28
|
}
|
|
25
29
|
|
|
30
|
+
/**
|
|
31
|
+
* Authenticated user context attached to a route request.
|
|
32
|
+
*
|
|
33
|
+
* Populated by the auth middleware when `RouteDefinition.auth` is `true`.
|
|
34
|
+
*/
|
|
35
|
+
export interface RouteUserContext {
|
|
36
|
+
/** Unique user identifier. */
|
|
37
|
+
userId: string;
|
|
38
|
+
/** User display name (optional). */
|
|
39
|
+
displayName?: string;
|
|
40
|
+
/** Roles assigned to the user (e.g. `['admin', 'user']`). */
|
|
41
|
+
roles?: string[];
|
|
42
|
+
/** Fine-grained permissions (e.g. `['ai:chat', 'ai:admin']`). */
|
|
43
|
+
permissions?: string[];
|
|
44
|
+
}
|
|
45
|
+
|
|
26
46
|
export interface RouteRequest {
|
|
27
47
|
/** Parsed JSON body (for POST requests) */
|
|
28
48
|
body?: unknown;
|
|
@@ -30,6 +50,8 @@ export interface RouteRequest {
|
|
|
30
50
|
params?: Record<string, string>;
|
|
31
51
|
/** Query string parameters */
|
|
32
52
|
query?: Record<string, string>;
|
|
53
|
+
/** Authenticated user context (populated by auth middleware). */
|
|
54
|
+
user?: RouteUserContext;
|
|
33
55
|
}
|
|
34
56
|
|
|
35
57
|
export interface RouteResponse {
|
|
@@ -94,6 +116,8 @@ export function buildAIRoutes(
|
|
|
94
116
|
method: 'POST',
|
|
95
117
|
path: '/api/v1/ai/chat',
|
|
96
118
|
description: 'Synchronous chat completion',
|
|
119
|
+
auth: true,
|
|
120
|
+
permissions: ['ai:chat'],
|
|
97
121
|
handler: async (req) => {
|
|
98
122
|
const { messages, options } = (req.body ?? {}) as {
|
|
99
123
|
messages?: unknown[];
|
|
@@ -124,6 +148,8 @@ export function buildAIRoutes(
|
|
|
124
148
|
method: 'POST',
|
|
125
149
|
path: '/api/v1/ai/chat/stream',
|
|
126
150
|
description: 'SSE streaming chat completion',
|
|
151
|
+
auth: true,
|
|
152
|
+
permissions: ['ai:chat'],
|
|
127
153
|
handler: async (req) => {
|
|
128
154
|
const { messages, options } = (req.body ?? {}) as {
|
|
129
155
|
messages?: unknown[];
|
|
@@ -157,6 +183,8 @@ export function buildAIRoutes(
|
|
|
157
183
|
method: 'POST',
|
|
158
184
|
path: '/api/v1/ai/complete',
|
|
159
185
|
description: 'Text completion',
|
|
186
|
+
auth: true,
|
|
187
|
+
permissions: ['ai:complete'],
|
|
160
188
|
handler: async (req) => {
|
|
161
189
|
const { prompt, options } = (req.body ?? {}) as {
|
|
162
190
|
prompt?: string;
|
|
@@ -182,6 +210,8 @@ export function buildAIRoutes(
|
|
|
182
210
|
method: 'GET',
|
|
183
211
|
path: '/api/v1/ai/models',
|
|
184
212
|
description: 'List available models',
|
|
213
|
+
auth: true,
|
|
214
|
+
permissions: ['ai:read'],
|
|
185
215
|
handler: async () => {
|
|
186
216
|
try {
|
|
187
217
|
const models = aiService.listModels ? await aiService.listModels() : [];
|
|
@@ -198,9 +228,20 @@ export function buildAIRoutes(
|
|
|
198
228
|
method: 'POST',
|
|
199
229
|
path: '/api/v1/ai/conversations',
|
|
200
230
|
description: 'Create a conversation',
|
|
231
|
+
auth: true,
|
|
232
|
+
permissions: ['ai:conversations'],
|
|
201
233
|
handler: async (req) => {
|
|
202
234
|
try {
|
|
203
|
-
|
|
235
|
+
// Ensure the request body is a non-null object before mutating it
|
|
236
|
+
if (req.body !== undefined && req.body !== null && (typeof req.body !== 'object' || Array.isArray(req.body))) {
|
|
237
|
+
return { status: 400, body: { error: 'Invalid request payload' } };
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
const options: Record<string, unknown> = { ...((req.body ?? {}) as Record<string, unknown>) };
|
|
241
|
+
// Bind the conversation to the authenticated user
|
|
242
|
+
if (req.user?.userId) {
|
|
243
|
+
options.userId = req.user.userId;
|
|
244
|
+
}
|
|
204
245
|
const conversation = await conversationService.create(options as any);
|
|
205
246
|
return { status: 201, body: conversation };
|
|
206
247
|
} catch (err) {
|
|
@@ -213,6 +254,8 @@ export function buildAIRoutes(
|
|
|
213
254
|
method: 'GET',
|
|
214
255
|
path: '/api/v1/ai/conversations',
|
|
215
256
|
description: 'List conversations',
|
|
257
|
+
auth: true,
|
|
258
|
+
permissions: ['ai:conversations'],
|
|
216
259
|
handler: async (req) => {
|
|
217
260
|
try {
|
|
218
261
|
const rawQuery = req.query ?? {};
|
|
@@ -226,6 +269,11 @@ export function buildAIRoutes(
|
|
|
226
269
|
options.limit = parsedLimit;
|
|
227
270
|
}
|
|
228
271
|
|
|
272
|
+
// Scope to the authenticated user's conversations
|
|
273
|
+
if (req.user?.userId) {
|
|
274
|
+
options.userId = req.user.userId;
|
|
275
|
+
}
|
|
276
|
+
|
|
229
277
|
const conversations = await conversationService.list(options as any);
|
|
230
278
|
return { status: 200, body: { conversations } };
|
|
231
279
|
} catch (err) {
|
|
@@ -238,6 +286,8 @@ export function buildAIRoutes(
|
|
|
238
286
|
method: 'POST',
|
|
239
287
|
path: '/api/v1/ai/conversations/:id/messages',
|
|
240
288
|
description: 'Add message to a conversation',
|
|
289
|
+
auth: true,
|
|
290
|
+
permissions: ['ai:conversations'],
|
|
241
291
|
handler: async (req) => {
|
|
242
292
|
const id = req.params?.id;
|
|
243
293
|
if (!id) {
|
|
@@ -251,6 +301,17 @@ export function buildAIRoutes(
|
|
|
251
301
|
}
|
|
252
302
|
|
|
253
303
|
try {
|
|
304
|
+
// Ownership check: verify the conversation belongs to the current user
|
|
305
|
+
if (req.user?.userId) {
|
|
306
|
+
const existing = await conversationService.get(id);
|
|
307
|
+
if (!existing) {
|
|
308
|
+
return { status: 404, body: { error: `Conversation "${id}" not found` } };
|
|
309
|
+
}
|
|
310
|
+
if (existing.userId && existing.userId !== req.user.userId) {
|
|
311
|
+
return { status: 403, body: { error: 'You do not have access to this conversation' } };
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
|
|
254
315
|
const conversation = await conversationService.addMessage(id, message as AIMessage);
|
|
255
316
|
return { status: 200, body: conversation };
|
|
256
317
|
} catch (err) {
|
|
@@ -267,6 +328,8 @@ export function buildAIRoutes(
|
|
|
267
328
|
method: 'DELETE',
|
|
268
329
|
path: '/api/v1/ai/conversations/:id',
|
|
269
330
|
description: 'Delete a conversation',
|
|
331
|
+
auth: true,
|
|
332
|
+
permissions: ['ai:conversations'],
|
|
270
333
|
handler: async (req) => {
|
|
271
334
|
const id = req.params?.id;
|
|
272
335
|
if (!id) {
|
|
@@ -274,6 +337,17 @@ export function buildAIRoutes(
|
|
|
274
337
|
}
|
|
275
338
|
|
|
276
339
|
try {
|
|
340
|
+
// Ownership check: verify the conversation belongs to the current user
|
|
341
|
+
if (req.user?.userId) {
|
|
342
|
+
const existing = await conversationService.get(id);
|
|
343
|
+
if (!existing) {
|
|
344
|
+
return { status: 404, body: { error: `Conversation "${id}" not found` } };
|
|
345
|
+
}
|
|
346
|
+
if (existing.userId && existing.userId !== req.user.userId) {
|
|
347
|
+
return { status: 403, body: { error: 'You do not have access to this conversation' } };
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
|
|
277
351
|
await conversationService.delete(id);
|
|
278
352
|
return { status: 204 };
|
|
279
353
|
} catch (err) {
|
package/src/routes/index.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
1
|
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
|
|
2
2
|
|
|
3
3
|
export { buildAIRoutes } from './ai-routes.js';
|
|
4
|
-
export type { RouteDefinition, RouteRequest, RouteResponse } from './ai-routes.js';
|
|
4
|
+
export type { RouteDefinition, RouteRequest, RouteResponse, RouteUserContext } from './ai-routes.js';
|