@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/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
- const options = req.body ?? {};
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) {