@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/dist/index.d.ts
CHANGED
|
@@ -116,6 +116,14 @@ declare class AIService implements IAIService {
|
|
|
116
116
|
* maximum number of iterations (`maxIterations`) is reached.
|
|
117
117
|
*/
|
|
118
118
|
chatWithTools(messages: AIMessage[], options?: ChatWithToolsOptions): Promise<AIResult>;
|
|
119
|
+
/**
|
|
120
|
+
* Stream chat with automatic tool call resolution.
|
|
121
|
+
*
|
|
122
|
+
* Works like {@link chatWithTools} but yields SSE events. When the model
|
|
123
|
+
* requests tool calls during streaming, they are executed and the results
|
|
124
|
+
* fed back until a final text stream is produced.
|
|
125
|
+
*/
|
|
126
|
+
streamChatWithTools(messages: AIMessage[], options?: ChatWithToolsOptions): AsyncIterable<AIStreamEvent>;
|
|
119
127
|
}
|
|
120
128
|
|
|
121
129
|
/**
|
|
@@ -3349,12 +3357,31 @@ interface RouteDefinition {
|
|
|
3349
3357
|
path: string;
|
|
3350
3358
|
/** Human-readable description */
|
|
3351
3359
|
description: string;
|
|
3360
|
+
/** Whether this route requires authentication (default: true). */
|
|
3361
|
+
auth?: boolean;
|
|
3362
|
+
/** Required permissions for accessing this route. */
|
|
3363
|
+
permissions?: string[];
|
|
3352
3364
|
/**
|
|
3353
3365
|
* Handler receives a plain request-like object and returns a response-like
|
|
3354
3366
|
* object. SSE responses set `stream: true` and provide an async iterable.
|
|
3355
3367
|
*/
|
|
3356
3368
|
handler: (req: RouteRequest) => Promise<RouteResponse>;
|
|
3357
3369
|
}
|
|
3370
|
+
/**
|
|
3371
|
+
* Authenticated user context attached to a route request.
|
|
3372
|
+
*
|
|
3373
|
+
* Populated by the auth middleware when `RouteDefinition.auth` is `true`.
|
|
3374
|
+
*/
|
|
3375
|
+
interface RouteUserContext {
|
|
3376
|
+
/** Unique user identifier. */
|
|
3377
|
+
userId: string;
|
|
3378
|
+
/** User display name (optional). */
|
|
3379
|
+
displayName?: string;
|
|
3380
|
+
/** Roles assigned to the user (e.g. `['admin', 'user']`). */
|
|
3381
|
+
roles?: string[];
|
|
3382
|
+
/** Fine-grained permissions (e.g. `['ai:chat', 'ai:admin']`). */
|
|
3383
|
+
permissions?: string[];
|
|
3384
|
+
}
|
|
3358
3385
|
interface RouteRequest {
|
|
3359
3386
|
/** Parsed JSON body (for POST requests) */
|
|
3360
3387
|
body?: unknown;
|
|
@@ -3362,6 +3389,8 @@ interface RouteRequest {
|
|
|
3362
3389
|
params?: Record<string, string>;
|
|
3363
3390
|
/** Query string parameters */
|
|
3364
3391
|
query?: Record<string, string>;
|
|
3392
|
+
/** Authenticated user context (populated by auth middleware). */
|
|
3393
|
+
user?: RouteUserContext;
|
|
3365
3394
|
}
|
|
3366
3395
|
interface RouteResponse {
|
|
3367
3396
|
/** HTTP status code */
|
|
@@ -3403,4 +3432,4 @@ declare function buildAIRoutes(aiService: IAIService, conversationService: IAICo
|
|
|
3403
3432
|
*/
|
|
3404
3433
|
declare function buildAgentRoutes(aiService: AIService, agentRuntime: AgentRuntime, logger: Logger): RouteDefinition[];
|
|
3405
3434
|
|
|
3406
|
-
export { AIService, type AIServiceConfig, AIServicePlugin, type AIServicePluginOptions, type AgentChatContext, AgentRuntime, AiConversationObject, AiMessageObject, DATA_CHAT_AGENT, DATA_TOOL_DEFINITIONS, type DataToolContext, InMemoryConversationService, MemoryLLMAdapter, ObjectQLConversationService, type RouteDefinition, type RouteRequest, type RouteResponse, type ToolHandler, ToolRegistry, buildAIRoutes, buildAgentRoutes, registerDataTools };
|
|
3435
|
+
export { AIService, type AIServiceConfig, AIServicePlugin, type AIServicePluginOptions, type AgentChatContext, AgentRuntime, AiConversationObject, AiMessageObject, DATA_CHAT_AGENT, DATA_TOOL_DEFINITIONS, type DataToolContext, InMemoryConversationService, MemoryLLMAdapter, ObjectQLConversationService, type RouteDefinition, type RouteRequest, type RouteResponse, type RouteUserContext, type ToolHandler, ToolRegistry, buildAIRoutes, buildAgentRoutes, registerDataTools };
|
package/dist/index.js
CHANGED
|
@@ -249,7 +249,7 @@ var _AIService = class _AIService {
|
|
|
249
249
|
* maximum number of iterations (`maxIterations`) is reached.
|
|
250
250
|
*/
|
|
251
251
|
async chatWithTools(messages, options) {
|
|
252
|
-
const { maxIterations: maxIter, ...restOptions } = options ?? {};
|
|
252
|
+
const { maxIterations: maxIter, onToolError, ...restOptions } = options ?? {};
|
|
253
253
|
const maxIterations = maxIter ?? _AIService.DEFAULT_MAX_ITERATIONS;
|
|
254
254
|
const registeredTools = this.toolRegistry.getAll();
|
|
255
255
|
const mergedTools = [
|
|
@@ -262,11 +262,13 @@ var _AIService = class _AIService {
|
|
|
262
262
|
toolChoice: mergedTools.length > 0 ? restOptions.toolChoice ?? "auto" : void 0
|
|
263
263
|
};
|
|
264
264
|
const conversation = [...messages];
|
|
265
|
+
const toolErrors = [];
|
|
265
266
|
this.logger.debug("[AI] chatWithTools start", {
|
|
266
267
|
messageCount: conversation.length,
|
|
267
268
|
toolCount: mergedTools.length,
|
|
268
269
|
maxIterations
|
|
269
270
|
});
|
|
271
|
+
let abortedByCallback = false;
|
|
270
272
|
for (let iteration = 0; iteration < maxIterations; iteration++) {
|
|
271
273
|
const result = await this.adapter.chat(conversation, chatOptions);
|
|
272
274
|
if (!result.toolCalls || result.toolCalls.length === 0) {
|
|
@@ -284,14 +286,36 @@ var _AIService = class _AIService {
|
|
|
284
286
|
});
|
|
285
287
|
const toolResults = await this.toolRegistry.executeAll(result.toolCalls);
|
|
286
288
|
for (const tr of toolResults) {
|
|
289
|
+
if (tr.isError) {
|
|
290
|
+
const matchedCall = result.toolCalls.find((tc) => tc.id === tr.toolCallId);
|
|
291
|
+
const toolName = matchedCall?.name ?? "unknown";
|
|
292
|
+
const errorEntry = { iteration, toolName, error: tr.content };
|
|
293
|
+
toolErrors.push(errorEntry);
|
|
294
|
+
this.logger.warn("[AI] chatWithTools tool error", errorEntry);
|
|
295
|
+
if (onToolError && matchedCall) {
|
|
296
|
+
const action = onToolError(matchedCall, tr.content);
|
|
297
|
+
if (action === "abort") {
|
|
298
|
+
abortedByCallback = true;
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
}
|
|
287
302
|
conversation.push({
|
|
288
303
|
role: "tool",
|
|
289
304
|
content: tr.content,
|
|
290
305
|
toolCallId: tr.toolCallId
|
|
291
306
|
});
|
|
292
307
|
}
|
|
308
|
+
if (abortedByCallback) {
|
|
309
|
+
break;
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
if (abortedByCallback) {
|
|
313
|
+
this.logger.warn("[AI] chatWithTools aborted by onToolError callback", { toolErrors });
|
|
314
|
+
} else {
|
|
315
|
+
this.logger.warn("[AI] chatWithTools max iterations reached, forcing final response", {
|
|
316
|
+
toolErrors: toolErrors.length > 0 ? toolErrors : void 0
|
|
317
|
+
});
|
|
293
318
|
}
|
|
294
|
-
this.logger.warn("[AI] chatWithTools max iterations reached, forcing final response");
|
|
295
319
|
const finalResult = await this.adapter.chat(conversation, {
|
|
296
320
|
...chatOptions,
|
|
297
321
|
tools: void 0,
|
|
@@ -299,6 +323,74 @@ var _AIService = class _AIService {
|
|
|
299
323
|
});
|
|
300
324
|
return finalResult;
|
|
301
325
|
}
|
|
326
|
+
/**
|
|
327
|
+
* Stream chat with automatic tool call resolution.
|
|
328
|
+
*
|
|
329
|
+
* Works like {@link chatWithTools} but yields SSE events. When the model
|
|
330
|
+
* requests tool calls during streaming, they are executed and the results
|
|
331
|
+
* fed back until a final text stream is produced.
|
|
332
|
+
*/
|
|
333
|
+
async *streamChatWithTools(messages, options) {
|
|
334
|
+
const { maxIterations: maxIter, onToolError, ...restOptions } = options ?? {};
|
|
335
|
+
const maxIterations = maxIter ?? _AIService.DEFAULT_MAX_ITERATIONS;
|
|
336
|
+
const registeredTools = this.toolRegistry.getAll();
|
|
337
|
+
const mergedTools = [
|
|
338
|
+
...registeredTools,
|
|
339
|
+
...restOptions.tools ?? []
|
|
340
|
+
];
|
|
341
|
+
const chatOptions = {
|
|
342
|
+
...restOptions,
|
|
343
|
+
tools: mergedTools.length > 0 ? mergedTools : void 0,
|
|
344
|
+
toolChoice: mergedTools.length > 0 ? restOptions.toolChoice ?? "auto" : void 0
|
|
345
|
+
};
|
|
346
|
+
const conversation = [...messages];
|
|
347
|
+
let abortedByCallback = false;
|
|
348
|
+
for (let iteration = 0; iteration < maxIterations; iteration++) {
|
|
349
|
+
const result2 = await this.adapter.chat(conversation, chatOptions);
|
|
350
|
+
if (!result2.toolCalls || result2.toolCalls.length === 0) {
|
|
351
|
+
yield { type: "text-delta", textDelta: result2.content };
|
|
352
|
+
yield { type: "finish", result: result2 };
|
|
353
|
+
return;
|
|
354
|
+
}
|
|
355
|
+
for (const tc of result2.toolCalls) {
|
|
356
|
+
yield { type: "tool-call", toolCall: tc };
|
|
357
|
+
}
|
|
358
|
+
conversation.push({
|
|
359
|
+
role: "assistant",
|
|
360
|
+
content: result2.content ?? "",
|
|
361
|
+
toolCalls: result2.toolCalls
|
|
362
|
+
});
|
|
363
|
+
const toolResults = await this.toolRegistry.executeAll(result2.toolCalls);
|
|
364
|
+
for (const tr of toolResults) {
|
|
365
|
+
if (tr.isError && onToolError) {
|
|
366
|
+
const matchedCall = result2.toolCalls.find((tc) => tc.id === tr.toolCallId);
|
|
367
|
+
if (matchedCall) {
|
|
368
|
+
const action = onToolError(matchedCall, tr.content);
|
|
369
|
+
if (action === "abort") {
|
|
370
|
+
abortedByCallback = true;
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
conversation.push({
|
|
375
|
+
role: "tool",
|
|
376
|
+
content: tr.content,
|
|
377
|
+
toolCallId: tr.toolCallId
|
|
378
|
+
});
|
|
379
|
+
}
|
|
380
|
+
if (abortedByCallback) {
|
|
381
|
+
break;
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
if (abortedByCallback) {
|
|
385
|
+
this.logger.warn("[AI] streamChatWithTools aborted by onToolError callback");
|
|
386
|
+
} else {
|
|
387
|
+
this.logger.warn("[AI] streamChatWithTools max iterations reached");
|
|
388
|
+
}
|
|
389
|
+
const finalOptions = { ...chatOptions, tools: void 0, toolChoice: void 0 };
|
|
390
|
+
const result = await this.adapter.chat(conversation, finalOptions);
|
|
391
|
+
yield { type: "text-delta", textDelta: result.content };
|
|
392
|
+
yield { type: "finish", result };
|
|
393
|
+
}
|
|
302
394
|
};
|
|
303
395
|
// ── Tool Call Loop ────────────────────────────────────────────
|
|
304
396
|
/** Default maximum iterations for the tool call loop. */
|
|
@@ -327,6 +419,8 @@ function buildAIRoutes(aiService, conversationService, logger) {
|
|
|
327
419
|
method: "POST",
|
|
328
420
|
path: "/api/v1/ai/chat",
|
|
329
421
|
description: "Synchronous chat completion",
|
|
422
|
+
auth: true,
|
|
423
|
+
permissions: ["ai:chat"],
|
|
330
424
|
handler: async (req) => {
|
|
331
425
|
const { messages, options } = req.body ?? {};
|
|
332
426
|
if (!Array.isArray(messages) || messages.length === 0) {
|
|
@@ -350,6 +444,8 @@ function buildAIRoutes(aiService, conversationService, logger) {
|
|
|
350
444
|
method: "POST",
|
|
351
445
|
path: "/api/v1/ai/chat/stream",
|
|
352
446
|
description: "SSE streaming chat completion",
|
|
447
|
+
auth: true,
|
|
448
|
+
permissions: ["ai:chat"],
|
|
353
449
|
handler: async (req) => {
|
|
354
450
|
const { messages, options } = req.body ?? {};
|
|
355
451
|
if (!Array.isArray(messages) || messages.length === 0) {
|
|
@@ -376,6 +472,8 @@ function buildAIRoutes(aiService, conversationService, logger) {
|
|
|
376
472
|
method: "POST",
|
|
377
473
|
path: "/api/v1/ai/complete",
|
|
378
474
|
description: "Text completion",
|
|
475
|
+
auth: true,
|
|
476
|
+
permissions: ["ai:complete"],
|
|
379
477
|
handler: async (req) => {
|
|
380
478
|
const { prompt, options } = req.body ?? {};
|
|
381
479
|
if (!prompt || typeof prompt !== "string") {
|
|
@@ -395,6 +493,8 @@ function buildAIRoutes(aiService, conversationService, logger) {
|
|
|
395
493
|
method: "GET",
|
|
396
494
|
path: "/api/v1/ai/models",
|
|
397
495
|
description: "List available models",
|
|
496
|
+
auth: true,
|
|
497
|
+
permissions: ["ai:read"],
|
|
398
498
|
handler: async () => {
|
|
399
499
|
try {
|
|
400
500
|
const models = aiService.listModels ? await aiService.listModels() : [];
|
|
@@ -410,9 +510,17 @@ function buildAIRoutes(aiService, conversationService, logger) {
|
|
|
410
510
|
method: "POST",
|
|
411
511
|
path: "/api/v1/ai/conversations",
|
|
412
512
|
description: "Create a conversation",
|
|
513
|
+
auth: true,
|
|
514
|
+
permissions: ["ai:conversations"],
|
|
413
515
|
handler: async (req) => {
|
|
414
516
|
try {
|
|
415
|
-
|
|
517
|
+
if (req.body !== void 0 && req.body !== null && (typeof req.body !== "object" || Array.isArray(req.body))) {
|
|
518
|
+
return { status: 400, body: { error: "Invalid request payload" } };
|
|
519
|
+
}
|
|
520
|
+
const options = { ...req.body ?? {} };
|
|
521
|
+
if (req.user?.userId) {
|
|
522
|
+
options.userId = req.user.userId;
|
|
523
|
+
}
|
|
416
524
|
const conversation = await conversationService.create(options);
|
|
417
525
|
return { status: 201, body: conversation };
|
|
418
526
|
} catch (err) {
|
|
@@ -425,6 +533,8 @@ function buildAIRoutes(aiService, conversationService, logger) {
|
|
|
425
533
|
method: "GET",
|
|
426
534
|
path: "/api/v1/ai/conversations",
|
|
427
535
|
description: "List conversations",
|
|
536
|
+
auth: true,
|
|
537
|
+
permissions: ["ai:conversations"],
|
|
428
538
|
handler: async (req) => {
|
|
429
539
|
try {
|
|
430
540
|
const rawQuery = req.query ?? {};
|
|
@@ -436,6 +546,9 @@ function buildAIRoutes(aiService, conversationService, logger) {
|
|
|
436
546
|
}
|
|
437
547
|
options.limit = parsedLimit;
|
|
438
548
|
}
|
|
549
|
+
if (req.user?.userId) {
|
|
550
|
+
options.userId = req.user.userId;
|
|
551
|
+
}
|
|
439
552
|
const conversations = await conversationService.list(options);
|
|
440
553
|
return { status: 200, body: { conversations } };
|
|
441
554
|
} catch (err) {
|
|
@@ -448,6 +561,8 @@ function buildAIRoutes(aiService, conversationService, logger) {
|
|
|
448
561
|
method: "POST",
|
|
449
562
|
path: "/api/v1/ai/conversations/:id/messages",
|
|
450
563
|
description: "Add message to a conversation",
|
|
564
|
+
auth: true,
|
|
565
|
+
permissions: ["ai:conversations"],
|
|
451
566
|
handler: async (req) => {
|
|
452
567
|
const id = req.params?.id;
|
|
453
568
|
if (!id) {
|
|
@@ -459,6 +574,15 @@ function buildAIRoutes(aiService, conversationService, logger) {
|
|
|
459
574
|
return { status: 400, body: { error: validationError } };
|
|
460
575
|
}
|
|
461
576
|
try {
|
|
577
|
+
if (req.user?.userId) {
|
|
578
|
+
const existing = await conversationService.get(id);
|
|
579
|
+
if (!existing) {
|
|
580
|
+
return { status: 404, body: { error: `Conversation "${id}" not found` } };
|
|
581
|
+
}
|
|
582
|
+
if (existing.userId && existing.userId !== req.user.userId) {
|
|
583
|
+
return { status: 403, body: { error: "You do not have access to this conversation" } };
|
|
584
|
+
}
|
|
585
|
+
}
|
|
462
586
|
const conversation = await conversationService.addMessage(id, message);
|
|
463
587
|
return { status: 200, body: conversation };
|
|
464
588
|
} catch (err) {
|
|
@@ -475,12 +599,23 @@ function buildAIRoutes(aiService, conversationService, logger) {
|
|
|
475
599
|
method: "DELETE",
|
|
476
600
|
path: "/api/v1/ai/conversations/:id",
|
|
477
601
|
description: "Delete a conversation",
|
|
602
|
+
auth: true,
|
|
603
|
+
permissions: ["ai:conversations"],
|
|
478
604
|
handler: async (req) => {
|
|
479
605
|
const id = req.params?.id;
|
|
480
606
|
if (!id) {
|
|
481
607
|
return { status: 400, body: { error: "conversation id is required" } };
|
|
482
608
|
}
|
|
483
609
|
try {
|
|
610
|
+
if (req.user?.userId) {
|
|
611
|
+
const existing = await conversationService.get(id);
|
|
612
|
+
if (!existing) {
|
|
613
|
+
return { status: 404, body: { error: `Conversation "${id}" not found` } };
|
|
614
|
+
}
|
|
615
|
+
if (existing.userId && existing.userId !== req.user.userId) {
|
|
616
|
+
return { status: 403, body: { error: "You do not have access to this conversation" } };
|
|
617
|
+
}
|
|
618
|
+
}
|
|
484
619
|
await conversationService.delete(id);
|
|
485
620
|
return { status: 204 };
|
|
486
621
|
} catch (err) {
|
|
@@ -513,6 +648,8 @@ function buildAgentRoutes(aiService, agentRuntime, logger) {
|
|
|
513
648
|
method: "POST",
|
|
514
649
|
path: "/api/v1/ai/agents/:agentName/chat",
|
|
515
650
|
description: "Chat with a specific AI agent",
|
|
651
|
+
auth: true,
|
|
652
|
+
permissions: ["ai:chat", "ai:agents"],
|
|
516
653
|
handler: async (req) => {
|
|
517
654
|
const agentName = req.params?.agentName;
|
|
518
655
|
if (!agentName) {
|