@objectstack/service-ai 4.0.3 → 4.0.5

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.
Files changed (52) hide show
  1. package/README.md +293 -0
  2. package/dist/index.cjs +1176 -135
  3. package/dist/index.cjs.map +1 -1
  4. package/dist/index.d.cts +1225 -430
  5. package/dist/index.d.ts +1225 -430
  6. package/dist/index.js +1160 -128
  7. package/dist/index.js.map +1 -1
  8. package/package.json +35 -8
  9. package/.turbo/turbo-build.log +0 -22
  10. package/CHANGELOG.md +0 -53
  11. package/src/__tests__/ai-service.test.ts +0 -964
  12. package/src/__tests__/auth-and-toolcalling.test.ts +0 -677
  13. package/src/__tests__/chatbot-features.test.ts +0 -1116
  14. package/src/__tests__/metadata-tools.test.ts +0 -970
  15. package/src/__tests__/objectql-conversation-service.test.ts +0 -382
  16. package/src/__tests__/tool-routes.test.ts +0 -191
  17. package/src/__tests__/vercel-stream-encoder.test.ts +0 -310
  18. package/src/adapters/index.ts +0 -6
  19. package/src/adapters/memory-adapter.ts +0 -72
  20. package/src/adapters/types.ts +0 -3
  21. package/src/adapters/vercel-adapter.ts +0 -148
  22. package/src/agent-runtime.ts +0 -154
  23. package/src/agents/data-chat-agent.ts +0 -79
  24. package/src/agents/index.ts +0 -4
  25. package/src/agents/metadata-assistant-agent.ts +0 -87
  26. package/src/ai-service.ts +0 -364
  27. package/src/conversation/in-memory-conversation-service.ts +0 -103
  28. package/src/conversation/index.ts +0 -4
  29. package/src/conversation/objectql-conversation-service.ts +0 -301
  30. package/src/index.ts +0 -60
  31. package/src/objects/ai-conversation.object.ts +0 -86
  32. package/src/objects/ai-message.object.ts +0 -86
  33. package/src/objects/index.ts +0 -10
  34. package/src/plugin.ts +0 -391
  35. package/src/routes/agent-routes.ts +0 -190
  36. package/src/routes/ai-routes.ts +0 -439
  37. package/src/routes/index.ts +0 -5
  38. package/src/routes/message-utils.ts +0 -90
  39. package/src/routes/tool-routes.ts +0 -142
  40. package/src/stream/index.ts +0 -3
  41. package/src/stream/vercel-stream-encoder.ts +0 -153
  42. package/src/tools/add-field.tool.ts +0 -70
  43. package/src/tools/create-object.tool.ts +0 -66
  44. package/src/tools/data-tools.ts +0 -293
  45. package/src/tools/delete-field.tool.ts +0 -38
  46. package/src/tools/describe-object.tool.ts +0 -31
  47. package/src/tools/index.ts +0 -18
  48. package/src/tools/list-objects.tool.ts +0 -34
  49. package/src/tools/metadata-tools.ts +0 -430
  50. package/src/tools/modify-field.tool.ts +0 -44
  51. package/src/tools/tool-registry.ts +0 -132
  52. package/tsconfig.json +0 -17
@@ -1,439 +0,0 @@
1
- // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2
-
3
- import type { IAIService, IAIConversationService, ModelMessage } from '@objectstack/spec/contracts';
4
- import type { Logger } from '@objectstack/spec/contracts';
5
- import { encodeVercelDataStream } from '../stream/vercel-stream-encoder.js';
6
- import { normalizeMessage, validateMessageContent } from './message-utils.js';
7
-
8
- /**
9
- * Minimal HTTP handler abstraction so routes stay framework-agnostic.
10
- *
11
- * Consumers wire these handlers to their HTTP server of choice
12
- * (Hono, Express, Fastify, etc.) via the kernel's HTTP server service.
13
- */
14
- export interface RouteDefinition {
15
- /** HTTP method */
16
- method: 'GET' | 'POST' | 'DELETE';
17
- /** Path pattern (e.g. '/api/v1/ai/chat') */
18
- path: string;
19
- /** Human-readable description */
20
- description: string;
21
- /** Whether this route requires authentication (default: true). */
22
- auth?: boolean;
23
- /** Required permissions for accessing this route. */
24
- permissions?: string[];
25
- /**
26
- * Handler receives a plain request-like object and returns a response-like
27
- * object. SSE responses set `stream: true` and provide an async iterable.
28
- */
29
- handler: (req: RouteRequest) => Promise<RouteResponse>;
30
- }
31
-
32
- /**
33
- * Authenticated user context attached to a route request.
34
- *
35
- * Populated by the auth middleware when `RouteDefinition.auth` is `true`.
36
- */
37
- export interface RouteUserContext {
38
- /** Unique user identifier. */
39
- userId: string;
40
- /** User display name (optional). */
41
- displayName?: string;
42
- /** Roles assigned to the user (e.g. `['admin', 'user']`). */
43
- roles?: string[];
44
- /** Fine-grained permissions (e.g. `['ai:chat', 'ai:admin']`). */
45
- permissions?: string[];
46
- }
47
-
48
- export interface RouteRequest {
49
- /** Parsed JSON body (for POST requests) */
50
- body?: unknown;
51
- /** Route/query parameters */
52
- params?: Record<string, string>;
53
- /** Query string parameters */
54
- query?: Record<string, string>;
55
- /** Authenticated user context (populated by auth middleware). */
56
- user?: RouteUserContext;
57
- }
58
-
59
- export interface RouteResponse {
60
- /** HTTP status code */
61
- status: number;
62
- /** JSON-serializable body (for non-streaming responses) */
63
- body?: unknown;
64
- /** If true, `stream` provides SSE events */
65
- stream?: boolean;
66
- /** Async iterable of SSE events (when stream=true) */
67
- events?: AsyncIterable<unknown>;
68
- /**
69
- * When `true`, the HTTP server layer should encode the `events` iterable
70
- * using the Vercel AI Data Stream Protocol frame format (`0:`, `9:`, `d:`, …)
71
- * instead of generic SSE `data:` lines.
72
- *
73
- * @see https://ai-sdk.dev/docs/ai-sdk-ui/stream-protocol
74
- */
75
- vercelDataStream?: boolean;
76
- }
77
-
78
- /** Valid message roles accepted by the AI routes. */
79
- const VALID_ROLES = new Set<string>(['system', 'user', 'assistant', 'tool']);
80
-
81
- /**
82
- * Validate that `raw` is a well-formed message.
83
- * Returns null on success, or an error string on failure.
84
- *
85
- * Accepts:
86
- * - Simple string `content` (legacy)
87
- * - Array `content` (e.g. `[{ type: 'text', text: '...' }]`)
88
- * - Vercel AI SDK v6 `parts` format (content may be absent/null)
89
- */
90
- function validateMessage(raw: unknown): string | null {
91
- if (typeof raw !== 'object' || raw === null) {
92
- return 'each message must be an object';
93
- }
94
- const msg = raw as Record<string, unknown>;
95
- if (typeof msg.role !== 'string' || !VALID_ROLES.has(msg.role)) {
96
- return `message.role must be one of ${[...VALID_ROLES].map(r => `"${r}"`).join(', ')}`;
97
- }
98
-
99
- // Assistant / tool messages may legitimately have null or missing content
100
- const allowEmpty = msg.role === 'assistant' || msg.role === 'tool';
101
- return validateMessageContent(msg, { allowEmptyContent: allowEmpty });
102
- }
103
-
104
- /**
105
- * Build the standard AI REST/SSE routes.
106
- *
107
- * Depends on contracts ({@link IAIService} + {@link IAIConversationService})
108
- * rather than concrete implementations, so any compliant service pair can
109
- * be wired in.
110
- *
111
- * Routes:
112
- * | Method | Path | Description |
113
- * |:---|:---|:---|
114
- * | POST | /api/v1/ai/chat | Synchronous chat completion |
115
- * | POST | /api/v1/ai/chat/stream | SSE streaming chat completion |
116
- * | POST | /api/v1/ai/complete | Text completion |
117
- * | GET | /api/v1/ai/models | List available models |
118
- * | POST | /api/v1/ai/conversations | Create a conversation |
119
- * | GET | /api/v1/ai/conversations | List conversations |
120
- * | POST | /api/v1/ai/conversations/:id/messages | Add message to conversation |
121
- * | DELETE | /api/v1/ai/conversations/:id | Delete conversation |
122
- */
123
- export function buildAIRoutes(
124
- aiService: IAIService,
125
- conversationService: IAIConversationService,
126
- logger: Logger,
127
- ): RouteDefinition[] {
128
- return [
129
- // ── Chat ────────────────────────────────────────────────────
130
- //
131
- // Dual-mode endpoint compatible with both the legacy ObjectStack
132
- // format (`{ messages, options }`) and the Vercel AI SDK useChat
133
- // flat format (`{ messages, system, model, stream, … }`).
134
- //
135
- // Behaviour:
136
- // • `stream !== false` → Vercel Data Stream Protocol (SSE)
137
- // • `stream === false` → JSON response (legacy)
138
- //
139
- {
140
- method: 'POST',
141
- path: '/api/v1/ai/chat',
142
- description: 'Chat completion (supports Vercel AI Data Stream Protocol)',
143
- auth: true,
144
- permissions: ['ai:chat'],
145
- handler: async (req) => {
146
- const body = (req.body ?? {}) as Record<string, unknown>;
147
-
148
- // ── Parse messages ───────────────────────────────────
149
- const messages = body.messages as unknown[] | undefined;
150
- if (!Array.isArray(messages) || messages.length === 0) {
151
- return { status: 400, body: { error: 'messages array is required' } };
152
- }
153
-
154
- for (const msg of messages) {
155
- const err = validateMessage(msg);
156
- if (err) return { status: 400, body: { error: err } };
157
- }
158
-
159
- // ── Resolve options ──────────────────────────────────
160
- // Accept legacy nested `options` object **or** Vercel-style
161
- // flat fields (`model`, `temperature`, `maxTokens`).
162
- const nested = (body.options ?? {}) as Record<string, unknown>;
163
- const resolvedOptions: Record<string, unknown> = {
164
- ...nested,
165
- ...(body.model != null && { model: body.model }),
166
- ...(body.temperature != null && { temperature: body.temperature }),
167
- ...(body.maxTokens != null && { maxTokens: body.maxTokens }),
168
- };
169
-
170
- // ── Prepend system prompt ────────────────────────────
171
- // Vercel useChat sends `system` (or the deprecated `systemPrompt`)
172
- // as a top-level field. We prepend it as a system message.
173
- const rawSystemPrompt = body.system ?? body.systemPrompt;
174
- if (rawSystemPrompt != null && typeof rawSystemPrompt !== 'string') {
175
- return { status: 400, body: { error: 'system/systemPrompt must be a string' } };
176
- }
177
- const systemPrompt = rawSystemPrompt as string | undefined;
178
- const finalMessages: ModelMessage[] = [
179
- ...(systemPrompt
180
- ? [{ role: 'system' as const, content: systemPrompt }]
181
- : []),
182
- ...messages.map(m => normalizeMessage(m as Record<string, unknown>)),
183
- ];
184
-
185
- // ── Choose response mode ─────────────────────────────
186
- const wantStream = body.stream !== false;
187
-
188
- if (wantStream) {
189
- // UI Message Stream Protocol (SSE with JSON payloads)
190
- try {
191
- if (!(aiService as any).streamChatWithTools) {
192
- return { status: 501, body: { error: 'Streaming is not supported by the configured AI service' } };
193
- }
194
- const events = (aiService as any).streamChatWithTools(finalMessages, resolvedOptions as any);
195
- return {
196
- status: 200,
197
- stream: true,
198
- vercelDataStream: true,
199
- contentType: 'text/event-stream',
200
- headers: {
201
- 'Content-Type': 'text/event-stream',
202
- 'Cache-Control': 'no-cache',
203
- 'Connection': 'keep-alive',
204
- 'x-vercel-ai-ui-message-stream': 'v1',
205
- },
206
- events: encodeVercelDataStream(events),
207
- };
208
- } catch (err) {
209
- logger.error('[AI Route] /chat stream error', err instanceof Error ? err : undefined);
210
- return { status: 500, body: { error: 'Internal AI service error' } };
211
- }
212
- }
213
-
214
- // JSON response (non-streaming)
215
- try {
216
- const result = await (aiService as any).chatWithTools(finalMessages, resolvedOptions as any);
217
- return { status: 200, body: result };
218
- } catch (err) {
219
- logger.error('[AI Route] /chat error', err instanceof Error ? err : undefined);
220
- return { status: 500, body: { error: 'Internal AI service error' } };
221
- }
222
- },
223
- },
224
-
225
- // ── Stream Chat (SSE) ──────────────────────────────────────
226
- {
227
- method: 'POST',
228
- path: '/api/v1/ai/chat/stream',
229
- description: 'SSE streaming chat completion',
230
- auth: true,
231
- permissions: ['ai:chat'],
232
- handler: async (req) => {
233
- const { messages, options } = (req.body ?? {}) as {
234
- messages?: unknown[];
235
- options?: Record<string, unknown>;
236
- };
237
-
238
- if (!Array.isArray(messages) || messages.length === 0) {
239
- return { status: 400, body: { error: 'messages array is required' } };
240
- }
241
-
242
- for (const msg of messages) {
243
- const err = validateMessage(msg);
244
- if (err) return { status: 400, body: { error: err } };
245
- }
246
-
247
- try {
248
- if (!aiService.streamChat) {
249
- return { status: 501, body: { error: 'Streaming is not supported by the configured AI service' } };
250
- }
251
- const events = aiService.streamChat(messages.map(m => normalizeMessage(m as Record<string, unknown>)), options as any);
252
- return { status: 200, stream: true, events };
253
- } catch (err) {
254
- logger.error('[AI Route] /chat/stream error', err instanceof Error ? err : undefined);
255
- return { status: 500, body: { error: 'Internal AI service error' } };
256
- }
257
- },
258
- },
259
-
260
- // ── Complete ────────────────────────────────────────────────
261
- {
262
- method: 'POST',
263
- path: '/api/v1/ai/complete',
264
- description: 'Text completion',
265
- auth: true,
266
- permissions: ['ai:complete'],
267
- handler: async (req) => {
268
- const { prompt, options } = (req.body ?? {}) as {
269
- prompt?: string;
270
- options?: Record<string, unknown>;
271
- };
272
-
273
- if (!prompt || typeof prompt !== 'string') {
274
- return { status: 400, body: { error: 'prompt string is required' } };
275
- }
276
-
277
- try {
278
- const result = await aiService.complete(prompt, options as any);
279
- return { status: 200, body: result };
280
- } catch (err) {
281
- logger.error('[AI Route] /complete error', err instanceof Error ? err : undefined);
282
- return { status: 500, body: { error: 'Internal AI service error' } };
283
- }
284
- },
285
- },
286
-
287
- // ── Models ──────────────────────────────────────────────────
288
- {
289
- method: 'GET',
290
- path: '/api/v1/ai/models',
291
- description: 'List available models',
292
- auth: true,
293
- permissions: ['ai:read'],
294
- handler: async () => {
295
- try {
296
- const models = aiService.listModels ? await aiService.listModels() : [];
297
- return { status: 200, body: { models } };
298
- } catch (err) {
299
- logger.error('[AI Route] /models error', err instanceof Error ? err : undefined);
300
- return { status: 500, body: { error: 'Internal AI service error' } };
301
- }
302
- },
303
- },
304
-
305
- // ── Conversations ──────────────────────────────────────────
306
- {
307
- method: 'POST',
308
- path: '/api/v1/ai/conversations',
309
- description: 'Create a conversation',
310
- auth: true,
311
- permissions: ['ai:conversations'],
312
- handler: async (req) => {
313
- try {
314
- // Ensure the request body is a non-null object before mutating it
315
- if (req.body !== undefined && req.body !== null && (typeof req.body !== 'object' || Array.isArray(req.body))) {
316
- return { status: 400, body: { error: 'Invalid request payload' } };
317
- }
318
-
319
- const options: Record<string, unknown> = { ...((req.body ?? {}) as Record<string, unknown>) };
320
- // Bind the conversation to the authenticated user
321
- if (req.user?.userId) {
322
- options.userId = req.user.userId;
323
- }
324
- const conversation = await conversationService.create(options as any);
325
- return { status: 201, body: conversation };
326
- } catch (err) {
327
- logger.error('[AI Route] POST /conversations error', err instanceof Error ? err : undefined);
328
- return { status: 500, body: { error: 'Internal AI service error' } };
329
- }
330
- },
331
- },
332
- {
333
- method: 'GET',
334
- path: '/api/v1/ai/conversations',
335
- description: 'List conversations',
336
- auth: true,
337
- permissions: ['ai:conversations'],
338
- handler: async (req) => {
339
- try {
340
- const rawQuery = req.query ?? {};
341
- const options: Record<string, unknown> = { ...rawQuery };
342
-
343
- if (typeof rawQuery.limit === 'string') {
344
- const parsedLimit = Number(rawQuery.limit);
345
- if (!Number.isFinite(parsedLimit) || parsedLimit <= 0 || !Number.isInteger(parsedLimit)) {
346
- return { status: 400, body: { error: 'Invalid limit parameter' } };
347
- }
348
- options.limit = parsedLimit;
349
- }
350
-
351
- // Scope to the authenticated user's conversations
352
- if (req.user?.userId) {
353
- options.userId = req.user.userId;
354
- }
355
-
356
- const conversations = await conversationService.list(options as any);
357
- return { status: 200, body: { conversations } };
358
- } catch (err) {
359
- logger.error('[AI Route] GET /conversations error', err instanceof Error ? err : undefined);
360
- return { status: 500, body: { error: 'Internal AI service error' } };
361
- }
362
- },
363
- },
364
- {
365
- method: 'POST',
366
- path: '/api/v1/ai/conversations/:id/messages',
367
- description: 'Add message to a conversation',
368
- auth: true,
369
- permissions: ['ai:conversations'],
370
- handler: async (req) => {
371
- const id = req.params?.id;
372
- if (!id) {
373
- return { status: 400, body: { error: 'conversation id is required' } };
374
- }
375
-
376
- const message = req.body;
377
- const validationError = validateMessage(message);
378
- if (validationError) {
379
- return { status: 400, body: { error: validationError } };
380
- }
381
-
382
- try {
383
- // Ownership check: verify the conversation belongs to the current user
384
- if (req.user?.userId) {
385
- const existing = await conversationService.get(id);
386
- if (!existing) {
387
- return { status: 404, body: { error: `Conversation "${id}" not found` } };
388
- }
389
- if (existing.userId && existing.userId !== req.user.userId) {
390
- return { status: 403, body: { error: 'You do not have access to this conversation' } };
391
- }
392
- }
393
-
394
- const conversation = await conversationService.addMessage(id, message as ModelMessage);
395
- return { status: 200, body: conversation };
396
- } catch (err) {
397
- const msg = err instanceof Error ? err.message : String(err);
398
- if (msg.includes('not found')) {
399
- return { status: 404, body: { error: msg } };
400
- }
401
- logger.error('[AI Route] POST /conversations/:id/messages error', err instanceof Error ? err : undefined);
402
- return { status: 500, body: { error: 'Internal AI service error' } };
403
- }
404
- },
405
- },
406
- {
407
- method: 'DELETE',
408
- path: '/api/v1/ai/conversations/:id',
409
- description: 'Delete a conversation',
410
- auth: true,
411
- permissions: ['ai:conversations'],
412
- handler: async (req) => {
413
- const id = req.params?.id;
414
- if (!id) {
415
- return { status: 400, body: { error: 'conversation id is required' } };
416
- }
417
-
418
- try {
419
- // Ownership check: verify the conversation belongs to the current user
420
- if (req.user?.userId) {
421
- const existing = await conversationService.get(id);
422
- if (!existing) {
423
- return { status: 404, body: { error: `Conversation "${id}" not found` } };
424
- }
425
- if (existing.userId && existing.userId !== req.user.userId) {
426
- return { status: 403, body: { error: 'You do not have access to this conversation' } };
427
- }
428
- }
429
-
430
- await conversationService.delete(id);
431
- return { status: 204 };
432
- } catch (err) {
433
- logger.error('[AI Route] DELETE /conversations/:id error', err instanceof Error ? err : undefined);
434
- return { status: 500, body: { error: 'Internal AI service error' } };
435
- }
436
- },
437
- },
438
- ];
439
- }
@@ -1,5 +0,0 @@
1
- // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2
-
3
- export { buildAIRoutes } from './ai-routes.js';
4
- export type { RouteDefinition, RouteRequest, RouteResponse, RouteUserContext } from './ai-routes.js';
5
- export { normalizeMessage, validateMessageContent } from './message-utils.js';
@@ -1,90 +0,0 @@
1
- // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2
-
3
- import type { ModelMessage } from '@objectstack/spec/contracts';
4
-
5
- /**
6
- * Normalize a Vercel AI SDK v6 message (which may use `parts` instead of
7
- * `content`) into a plain `{ role, content }` ModelMessage.
8
- *
9
- * Shared between the general chat routes and agent chat routes.
10
- */
11
- export function normalizeMessage(raw: Record<string, unknown>): ModelMessage {
12
- const role = raw.role as string;
13
-
14
- // If content is already a string, use it directly
15
- if (typeof raw.content === 'string') {
16
- return { role, content: raw.content } as unknown as ModelMessage;
17
- }
18
-
19
- // If content is an array (multi-part), pass through
20
- if (Array.isArray(raw.content)) {
21
- return { role, content: raw.content } as unknown as ModelMessage;
22
- }
23
-
24
- // Vercel AI SDK v6: extract text from `parts` array
25
- if (Array.isArray(raw.parts)) {
26
- const textParts = (raw.parts as Array<Record<string, unknown>>)
27
- .filter(p => p.type === 'text' && typeof p.text === 'string')
28
- .map(p => p.text as string);
29
- if (textParts.length > 0) {
30
- return { role, content: textParts.join('') } as unknown as ModelMessage;
31
- }
32
- }
33
-
34
- // Fallback: empty content (e.g. tool-only assistant messages)
35
- return { role, content: '' } as unknown as ModelMessage;
36
- }
37
-
38
- /**
39
- * Validate message content/parts format (role-agnostic).
40
- *
41
- * Returns `null` when the content shape is valid, or an error string
42
- * describing the first violation found.
43
- *
44
- * Accepts:
45
- * - Simple string `content` (legacy)
46
- * - Array `content` (e.g. `[{ type: 'text', text: '...' }]`)
47
- * - Vercel AI SDK v6 `parts` format (content may be absent/null)
48
- * - Null/undefined `content` for assistant messages (when `allowEmpty` is true)
49
- */
50
- export function validateMessageContent(
51
- msg: Record<string, unknown>,
52
- opts?: { allowEmptyContent?: boolean },
53
- ): string | null {
54
- const content = msg.content;
55
-
56
- // Vercel AI SDK v6 sends `parts` instead of (or alongside) `content`.
57
- // Accept any message that carries a `parts` array, even when `content` is absent.
58
- if (Array.isArray(msg.parts)) {
59
- return null;
60
- }
61
-
62
- // content is a plain string — OK
63
- if (typeof content === 'string') {
64
- return null;
65
- }
66
-
67
- // content is an array of typed parts (legacy multi-part format)
68
- if (Array.isArray(content)) {
69
- for (const part of content as unknown[]) {
70
- if (typeof part !== 'object' || part === null) {
71
- return 'message.content array elements must be non-null objects';
72
- }
73
- const partObj = part as Record<string, unknown>;
74
- if (typeof partObj.type !== 'string') {
75
- return 'each message.content array element must have a string "type" property';
76
- }
77
- if (partObj.type === 'text' && typeof partObj.text !== 'string') {
78
- return 'message.content elements with type "text" must have a string "text" property';
79
- }
80
- }
81
- return null;
82
- }
83
-
84
- // Allow empty content for certain roles (e.g. assistant tool-call messages)
85
- if ((content === null || content === undefined) && opts?.allowEmptyContent) {
86
- return null;
87
- }
88
-
89
- return 'message.content must be a string, an array, or include parts';
90
- }
@@ -1,142 +0,0 @@
1
- // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2
-
3
- import type { Logger } from '@objectstack/spec/contracts';
4
- import type { AIService } from '../ai-service.js';
5
- import type { RouteDefinition } from './ai-routes.js';
6
-
7
- /**
8
- * Extract string value from tool execution result output.
9
- */
10
- function extractOutputValue(output: any): string {
11
- if (!output) return '';
12
- if (typeof output === 'string') return output;
13
- if (typeof output === 'object' && 'value' in output) {
14
- return String(output.value ?? '');
15
- }
16
- return JSON.stringify(output);
17
- }
18
-
19
- /**
20
- * Build tool-specific REST routes.
21
- *
22
- * | Method | Path | Description |
23
- * |:---|:---|:---|
24
- * | GET | /api/v1/ai/tools | List all registered tools |
25
- * | POST | /api/v1/ai/tools/:toolName/execute | Execute a tool with parameters |
26
- */
27
- export function buildToolRoutes(
28
- aiService: AIService,
29
- logger: Logger,
30
- ): RouteDefinition[] {
31
- return [
32
- // ── List registered tools ──────────────────────────────────────
33
- {
34
- method: 'GET',
35
- path: '/api/v1/ai/tools',
36
- description: 'List all registered AI tools',
37
- auth: true,
38
- permissions: ['ai:tools'],
39
- handler: async () => {
40
- try {
41
- const tools = aiService.toolRegistry.getAll();
42
- return {
43
- status: 200,
44
- body: {
45
- tools: tools.map(t => ({
46
- name: t.name,
47
- description: t.description,
48
- category: (t as any).category,
49
- }))
50
- }
51
- };
52
- } catch (err) {
53
- logger.error(
54
- '[AI Route] /tools list error',
55
- err instanceof Error ? err : undefined,
56
- );
57
- return { status: 500, body: { error: 'Internal AI service error' } };
58
- }
59
- },
60
- },
61
-
62
- // ── Execute a tool ──────────────────────────────────────────────
63
- //
64
- // Executes a tool with the provided parameters.
65
- // This is intended for testing/playground use.
66
- //
67
- {
68
- method: 'POST',
69
- path: '/api/v1/ai/tools/:toolName/execute',
70
- description: 'Execute a tool with parameters (playground/testing)',
71
- auth: true,
72
- permissions: ['ai:tools', 'ai:execute'],
73
- handler: async (req) => {
74
- const toolName = req.params?.toolName;
75
- if (!toolName) {
76
- return { status: 400, body: { error: 'toolName parameter is required' } };
77
- }
78
-
79
- // Parse request body
80
- const body = (req.body ?? {}) as Record<string, unknown>;
81
- const { parameters } = body as {
82
- parameters?: Record<string, unknown>;
83
- };
84
-
85
- if (!parameters || typeof parameters !== 'object') {
86
- return { status: 400, body: { error: 'parameters object is required' } };
87
- }
88
-
89
- try {
90
- // Check if tool exists
91
- if (!aiService.toolRegistry.has(toolName)) {
92
- return { status: 404, body: { error: `Tool "${toolName}" not found` } };
93
- }
94
-
95
- // Execute the tool using ToolRegistry's execute method
96
- const startTime = Date.now();
97
-
98
- const toolCallPart = {
99
- type: 'tool-call' as const,
100
- toolCallId: `playground-${Date.now()}`,
101
- toolName,
102
- input: parameters,
103
- };
104
-
105
- const result = await aiService.toolRegistry.execute(toolCallPart);
106
- const duration = Date.now() - startTime;
107
-
108
- // Check if execution resulted in an error
109
- if (result.isError) {
110
- const errorMsg = extractOutputValue(result.output);
111
- logger.error(
112
- `[AI Route] Tool execution error: ${toolName}`,
113
- new Error(errorMsg),
114
- );
115
- return {
116
- status: 500,
117
- body: {
118
- error: errorMsg,
119
- duration,
120
- },
121
- };
122
- }
123
-
124
- return {
125
- status: 200,
126
- body: {
127
- result: extractOutputValue(result.output),
128
- duration,
129
- toolName,
130
- },
131
- };
132
- } catch (err) {
133
- logger.error(
134
- '[AI Route] /tools/:toolName/execute error',
135
- err instanceof Error ? err : undefined,
136
- );
137
- return { status: 500, body: { error: 'Internal AI service error' } };
138
- }
139
- },
140
- },
141
- ];
142
- }
@@ -1,3 +0,0 @@
1
- // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2
-
3
- export { encodeStreamPart, encodeVercelDataStream } from './vercel-stream-encoder.js';