@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/.turbo/turbo-build.log
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
|
|
2
|
-
> @objectstack/service-ai@4.0.
|
|
2
|
+
> @objectstack/service-ai@4.0.1 build /home/runner/work/spec/spec/packages/services/service-ai
|
|
3
3
|
> tsup --config ../../../tsup.config.ts
|
|
4
4
|
|
|
5
5
|
[34mCLI[39m Building entry: src/index.ts
|
|
@@ -10,13 +10,13 @@
|
|
|
10
10
|
[34mCLI[39m Cleaning output folder
|
|
11
11
|
[34mESM[39m Build start
|
|
12
12
|
[34mCJS[39m Build start
|
|
13
|
-
[
|
|
14
|
-
[
|
|
15
|
-
[
|
|
16
|
-
[
|
|
17
|
-
[
|
|
18
|
-
[
|
|
13
|
+
[32mESM[39m [1mdist/index.js [22m[32m49.00 KB[39m
|
|
14
|
+
[32mESM[39m [1mdist/index.js.map [22m[32m106.10 KB[39m
|
|
15
|
+
[32mESM[39m ⚡️ Build success in 168ms
|
|
16
|
+
[32mCJS[39m [1mdist/index.cjs [22m[32m50.82 KB[39m
|
|
17
|
+
[32mCJS[39m [1mdist/index.cjs.map [22m[32m107.70 KB[39m
|
|
18
|
+
[32mCJS[39m ⚡️ Build success in 178ms
|
|
19
19
|
[34mDTS[39m Build start
|
|
20
|
-
[32mDTS[39m ⚡️ Build success in
|
|
21
|
-
[32mDTS[39m [1mdist/index.d.ts [22m[
|
|
22
|
-
[32mDTS[39m [1mdist/index.d.cts [22m[
|
|
20
|
+
[32mDTS[39m ⚡️ Build success in 18643ms
|
|
21
|
+
[32mDTS[39m [1mdist/index.d.ts [22m[32m149.83 KB[39m
|
|
22
|
+
[32mDTS[39m [1mdist/index.d.cts [22m[32m149.83 KB[39m
|
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,16 @@
|
|
|
1
1
|
# @objectstack/service-ai
|
|
2
2
|
|
|
3
|
+
## 4.1.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- **Route auth/permissions metadata**: Every route definition (`RouteDefinition`) now declares `auth` and `permissions` fields, enabling HTTP server adapters to enforce authentication and authorization automatically.
|
|
8
|
+
- **User context on RouteRequest**: `RouteRequest` now carries an optional `user: RouteUserContext` object populated by the auth middleware, providing `userId`, `displayName`, `roles`, and `permissions`.
|
|
9
|
+
- **Conversation ownership enforcement**: Conversation routes (create, list, add message, delete) are scoped to the authenticated user when a user context is present and the conversation has a `userId`. For backward compatibility, requests without user context and conversations created without a `userId` remain accessible under the existing behavior.
|
|
10
|
+
- **Enhanced tool-call loop error handling**: `chatWithTools` now tracks tool execution errors across iterations and supports an `onToolError` callback (`'continue'` | `'abort'`) for fine-grained error control.
|
|
11
|
+
- **`streamChatWithTools`**: New streaming tool-call loop that yields SSE events while automatically resolving intermediate tool calls.
|
|
12
|
+
- **New `RouteUserContext` type**: Exported from the package for use by HTTP adapters and middleware.
|
|
13
|
+
|
|
3
14
|
## 4.0.0
|
|
4
15
|
|
|
5
16
|
### Major Changes
|
package/dist/index.cjs
CHANGED
|
@@ -288,7 +288,7 @@ var _AIService = class _AIService {
|
|
|
288
288
|
* maximum number of iterations (`maxIterations`) is reached.
|
|
289
289
|
*/
|
|
290
290
|
async chatWithTools(messages, options) {
|
|
291
|
-
const { maxIterations: maxIter, ...restOptions } = options ?? {};
|
|
291
|
+
const { maxIterations: maxIter, onToolError, ...restOptions } = options ?? {};
|
|
292
292
|
const maxIterations = maxIter ?? _AIService.DEFAULT_MAX_ITERATIONS;
|
|
293
293
|
const registeredTools = this.toolRegistry.getAll();
|
|
294
294
|
const mergedTools = [
|
|
@@ -301,11 +301,13 @@ var _AIService = class _AIService {
|
|
|
301
301
|
toolChoice: mergedTools.length > 0 ? restOptions.toolChoice ?? "auto" : void 0
|
|
302
302
|
};
|
|
303
303
|
const conversation = [...messages];
|
|
304
|
+
const toolErrors = [];
|
|
304
305
|
this.logger.debug("[AI] chatWithTools start", {
|
|
305
306
|
messageCount: conversation.length,
|
|
306
307
|
toolCount: mergedTools.length,
|
|
307
308
|
maxIterations
|
|
308
309
|
});
|
|
310
|
+
let abortedByCallback = false;
|
|
309
311
|
for (let iteration = 0; iteration < maxIterations; iteration++) {
|
|
310
312
|
const result = await this.adapter.chat(conversation, chatOptions);
|
|
311
313
|
if (!result.toolCalls || result.toolCalls.length === 0) {
|
|
@@ -323,14 +325,36 @@ var _AIService = class _AIService {
|
|
|
323
325
|
});
|
|
324
326
|
const toolResults = await this.toolRegistry.executeAll(result.toolCalls);
|
|
325
327
|
for (const tr of toolResults) {
|
|
328
|
+
if (tr.isError) {
|
|
329
|
+
const matchedCall = result.toolCalls.find((tc) => tc.id === tr.toolCallId);
|
|
330
|
+
const toolName = matchedCall?.name ?? "unknown";
|
|
331
|
+
const errorEntry = { iteration, toolName, error: tr.content };
|
|
332
|
+
toolErrors.push(errorEntry);
|
|
333
|
+
this.logger.warn("[AI] chatWithTools tool error", errorEntry);
|
|
334
|
+
if (onToolError && matchedCall) {
|
|
335
|
+
const action = onToolError(matchedCall, tr.content);
|
|
336
|
+
if (action === "abort") {
|
|
337
|
+
abortedByCallback = true;
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
}
|
|
326
341
|
conversation.push({
|
|
327
342
|
role: "tool",
|
|
328
343
|
content: tr.content,
|
|
329
344
|
toolCallId: tr.toolCallId
|
|
330
345
|
});
|
|
331
346
|
}
|
|
347
|
+
if (abortedByCallback) {
|
|
348
|
+
break;
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
if (abortedByCallback) {
|
|
352
|
+
this.logger.warn("[AI] chatWithTools aborted by onToolError callback", { toolErrors });
|
|
353
|
+
} else {
|
|
354
|
+
this.logger.warn("[AI] chatWithTools max iterations reached, forcing final response", {
|
|
355
|
+
toolErrors: toolErrors.length > 0 ? toolErrors : void 0
|
|
356
|
+
});
|
|
332
357
|
}
|
|
333
|
-
this.logger.warn("[AI] chatWithTools max iterations reached, forcing final response");
|
|
334
358
|
const finalResult = await this.adapter.chat(conversation, {
|
|
335
359
|
...chatOptions,
|
|
336
360
|
tools: void 0,
|
|
@@ -338,6 +362,74 @@ var _AIService = class _AIService {
|
|
|
338
362
|
});
|
|
339
363
|
return finalResult;
|
|
340
364
|
}
|
|
365
|
+
/**
|
|
366
|
+
* Stream chat with automatic tool call resolution.
|
|
367
|
+
*
|
|
368
|
+
* Works like {@link chatWithTools} but yields SSE events. When the model
|
|
369
|
+
* requests tool calls during streaming, they are executed and the results
|
|
370
|
+
* fed back until a final text stream is produced.
|
|
371
|
+
*/
|
|
372
|
+
async *streamChatWithTools(messages, options) {
|
|
373
|
+
const { maxIterations: maxIter, onToolError, ...restOptions } = options ?? {};
|
|
374
|
+
const maxIterations = maxIter ?? _AIService.DEFAULT_MAX_ITERATIONS;
|
|
375
|
+
const registeredTools = this.toolRegistry.getAll();
|
|
376
|
+
const mergedTools = [
|
|
377
|
+
...registeredTools,
|
|
378
|
+
...restOptions.tools ?? []
|
|
379
|
+
];
|
|
380
|
+
const chatOptions = {
|
|
381
|
+
...restOptions,
|
|
382
|
+
tools: mergedTools.length > 0 ? mergedTools : void 0,
|
|
383
|
+
toolChoice: mergedTools.length > 0 ? restOptions.toolChoice ?? "auto" : void 0
|
|
384
|
+
};
|
|
385
|
+
const conversation = [...messages];
|
|
386
|
+
let abortedByCallback = false;
|
|
387
|
+
for (let iteration = 0; iteration < maxIterations; iteration++) {
|
|
388
|
+
const result2 = await this.adapter.chat(conversation, chatOptions);
|
|
389
|
+
if (!result2.toolCalls || result2.toolCalls.length === 0) {
|
|
390
|
+
yield { type: "text-delta", textDelta: result2.content };
|
|
391
|
+
yield { type: "finish", result: result2 };
|
|
392
|
+
return;
|
|
393
|
+
}
|
|
394
|
+
for (const tc of result2.toolCalls) {
|
|
395
|
+
yield { type: "tool-call", toolCall: tc };
|
|
396
|
+
}
|
|
397
|
+
conversation.push({
|
|
398
|
+
role: "assistant",
|
|
399
|
+
content: result2.content ?? "",
|
|
400
|
+
toolCalls: result2.toolCalls
|
|
401
|
+
});
|
|
402
|
+
const toolResults = await this.toolRegistry.executeAll(result2.toolCalls);
|
|
403
|
+
for (const tr of toolResults) {
|
|
404
|
+
if (tr.isError && onToolError) {
|
|
405
|
+
const matchedCall = result2.toolCalls.find((tc) => tc.id === tr.toolCallId);
|
|
406
|
+
if (matchedCall) {
|
|
407
|
+
const action = onToolError(matchedCall, tr.content);
|
|
408
|
+
if (action === "abort") {
|
|
409
|
+
abortedByCallback = true;
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
conversation.push({
|
|
414
|
+
role: "tool",
|
|
415
|
+
content: tr.content,
|
|
416
|
+
toolCallId: tr.toolCallId
|
|
417
|
+
});
|
|
418
|
+
}
|
|
419
|
+
if (abortedByCallback) {
|
|
420
|
+
break;
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
if (abortedByCallback) {
|
|
424
|
+
this.logger.warn("[AI] streamChatWithTools aborted by onToolError callback");
|
|
425
|
+
} else {
|
|
426
|
+
this.logger.warn("[AI] streamChatWithTools max iterations reached");
|
|
427
|
+
}
|
|
428
|
+
const finalOptions = { ...chatOptions, tools: void 0, toolChoice: void 0 };
|
|
429
|
+
const result = await this.adapter.chat(conversation, finalOptions);
|
|
430
|
+
yield { type: "text-delta", textDelta: result.content };
|
|
431
|
+
yield { type: "finish", result };
|
|
432
|
+
}
|
|
341
433
|
};
|
|
342
434
|
// ── Tool Call Loop ────────────────────────────────────────────
|
|
343
435
|
/** Default maximum iterations for the tool call loop. */
|
|
@@ -366,6 +458,8 @@ function buildAIRoutes(aiService, conversationService, logger) {
|
|
|
366
458
|
method: "POST",
|
|
367
459
|
path: "/api/v1/ai/chat",
|
|
368
460
|
description: "Synchronous chat completion",
|
|
461
|
+
auth: true,
|
|
462
|
+
permissions: ["ai:chat"],
|
|
369
463
|
handler: async (req) => {
|
|
370
464
|
const { messages, options } = req.body ?? {};
|
|
371
465
|
if (!Array.isArray(messages) || messages.length === 0) {
|
|
@@ -389,6 +483,8 @@ function buildAIRoutes(aiService, conversationService, logger) {
|
|
|
389
483
|
method: "POST",
|
|
390
484
|
path: "/api/v1/ai/chat/stream",
|
|
391
485
|
description: "SSE streaming chat completion",
|
|
486
|
+
auth: true,
|
|
487
|
+
permissions: ["ai:chat"],
|
|
392
488
|
handler: async (req) => {
|
|
393
489
|
const { messages, options } = req.body ?? {};
|
|
394
490
|
if (!Array.isArray(messages) || messages.length === 0) {
|
|
@@ -415,6 +511,8 @@ function buildAIRoutes(aiService, conversationService, logger) {
|
|
|
415
511
|
method: "POST",
|
|
416
512
|
path: "/api/v1/ai/complete",
|
|
417
513
|
description: "Text completion",
|
|
514
|
+
auth: true,
|
|
515
|
+
permissions: ["ai:complete"],
|
|
418
516
|
handler: async (req) => {
|
|
419
517
|
const { prompt, options } = req.body ?? {};
|
|
420
518
|
if (!prompt || typeof prompt !== "string") {
|
|
@@ -434,6 +532,8 @@ function buildAIRoutes(aiService, conversationService, logger) {
|
|
|
434
532
|
method: "GET",
|
|
435
533
|
path: "/api/v1/ai/models",
|
|
436
534
|
description: "List available models",
|
|
535
|
+
auth: true,
|
|
536
|
+
permissions: ["ai:read"],
|
|
437
537
|
handler: async () => {
|
|
438
538
|
try {
|
|
439
539
|
const models = aiService.listModels ? await aiService.listModels() : [];
|
|
@@ -449,9 +549,17 @@ function buildAIRoutes(aiService, conversationService, logger) {
|
|
|
449
549
|
method: "POST",
|
|
450
550
|
path: "/api/v1/ai/conversations",
|
|
451
551
|
description: "Create a conversation",
|
|
552
|
+
auth: true,
|
|
553
|
+
permissions: ["ai:conversations"],
|
|
452
554
|
handler: async (req) => {
|
|
453
555
|
try {
|
|
454
|
-
|
|
556
|
+
if (req.body !== void 0 && req.body !== null && (typeof req.body !== "object" || Array.isArray(req.body))) {
|
|
557
|
+
return { status: 400, body: { error: "Invalid request payload" } };
|
|
558
|
+
}
|
|
559
|
+
const options = { ...req.body ?? {} };
|
|
560
|
+
if (req.user?.userId) {
|
|
561
|
+
options.userId = req.user.userId;
|
|
562
|
+
}
|
|
455
563
|
const conversation = await conversationService.create(options);
|
|
456
564
|
return { status: 201, body: conversation };
|
|
457
565
|
} catch (err) {
|
|
@@ -464,6 +572,8 @@ function buildAIRoutes(aiService, conversationService, logger) {
|
|
|
464
572
|
method: "GET",
|
|
465
573
|
path: "/api/v1/ai/conversations",
|
|
466
574
|
description: "List conversations",
|
|
575
|
+
auth: true,
|
|
576
|
+
permissions: ["ai:conversations"],
|
|
467
577
|
handler: async (req) => {
|
|
468
578
|
try {
|
|
469
579
|
const rawQuery = req.query ?? {};
|
|
@@ -475,6 +585,9 @@ function buildAIRoutes(aiService, conversationService, logger) {
|
|
|
475
585
|
}
|
|
476
586
|
options.limit = parsedLimit;
|
|
477
587
|
}
|
|
588
|
+
if (req.user?.userId) {
|
|
589
|
+
options.userId = req.user.userId;
|
|
590
|
+
}
|
|
478
591
|
const conversations = await conversationService.list(options);
|
|
479
592
|
return { status: 200, body: { conversations } };
|
|
480
593
|
} catch (err) {
|
|
@@ -487,6 +600,8 @@ function buildAIRoutes(aiService, conversationService, logger) {
|
|
|
487
600
|
method: "POST",
|
|
488
601
|
path: "/api/v1/ai/conversations/:id/messages",
|
|
489
602
|
description: "Add message to a conversation",
|
|
603
|
+
auth: true,
|
|
604
|
+
permissions: ["ai:conversations"],
|
|
490
605
|
handler: async (req) => {
|
|
491
606
|
const id = req.params?.id;
|
|
492
607
|
if (!id) {
|
|
@@ -498,6 +613,15 @@ function buildAIRoutes(aiService, conversationService, logger) {
|
|
|
498
613
|
return { status: 400, body: { error: validationError } };
|
|
499
614
|
}
|
|
500
615
|
try {
|
|
616
|
+
if (req.user?.userId) {
|
|
617
|
+
const existing = await conversationService.get(id);
|
|
618
|
+
if (!existing) {
|
|
619
|
+
return { status: 404, body: { error: `Conversation "${id}" not found` } };
|
|
620
|
+
}
|
|
621
|
+
if (existing.userId && existing.userId !== req.user.userId) {
|
|
622
|
+
return { status: 403, body: { error: "You do not have access to this conversation" } };
|
|
623
|
+
}
|
|
624
|
+
}
|
|
501
625
|
const conversation = await conversationService.addMessage(id, message);
|
|
502
626
|
return { status: 200, body: conversation };
|
|
503
627
|
} catch (err) {
|
|
@@ -514,12 +638,23 @@ function buildAIRoutes(aiService, conversationService, logger) {
|
|
|
514
638
|
method: "DELETE",
|
|
515
639
|
path: "/api/v1/ai/conversations/:id",
|
|
516
640
|
description: "Delete a conversation",
|
|
641
|
+
auth: true,
|
|
642
|
+
permissions: ["ai:conversations"],
|
|
517
643
|
handler: async (req) => {
|
|
518
644
|
const id = req.params?.id;
|
|
519
645
|
if (!id) {
|
|
520
646
|
return { status: 400, body: { error: "conversation id is required" } };
|
|
521
647
|
}
|
|
522
648
|
try {
|
|
649
|
+
if (req.user?.userId) {
|
|
650
|
+
const existing = await conversationService.get(id);
|
|
651
|
+
if (!existing) {
|
|
652
|
+
return { status: 404, body: { error: `Conversation "${id}" not found` } };
|
|
653
|
+
}
|
|
654
|
+
if (existing.userId && existing.userId !== req.user.userId) {
|
|
655
|
+
return { status: 403, body: { error: "You do not have access to this conversation" } };
|
|
656
|
+
}
|
|
657
|
+
}
|
|
523
658
|
await conversationService.delete(id);
|
|
524
659
|
return { status: 204 };
|
|
525
660
|
} catch (err) {
|
|
@@ -552,6 +687,8 @@ function buildAgentRoutes(aiService, agentRuntime, logger) {
|
|
|
552
687
|
method: "POST",
|
|
553
688
|
path: "/api/v1/ai/agents/:agentName/chat",
|
|
554
689
|
description: "Chat with a specific AI agent",
|
|
690
|
+
auth: true,
|
|
691
|
+
permissions: ["ai:chat", "ai:agents"],
|
|
555
692
|
handler: async (req) => {
|
|
556
693
|
const agentName = req.params?.agentName;
|
|
557
694
|
if (!agentName) {
|