@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,964 +0,0 @@
1
- // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2
-
3
- import { describe, it, expect, vi, beforeEach } from 'vitest';
4
- import type { ModelMessage, IAIService, TextStreamPart, ToolSet } from '@objectstack/spec/contracts';
5
- import { AIService } from '../ai-service.js';
6
- import { MemoryLLMAdapter } from '../adapters/memory-adapter.js';
7
- import { ToolRegistry } from '../tools/tool-registry.js';
8
- import { InMemoryConversationService } from '../conversation/in-memory-conversation-service.js';
9
- import { buildAIRoutes } from '../routes/ai-routes.js';
10
- import { AIServicePlugin } from '../plugin.js';
11
- import type { LLMAdapter } from '@objectstack/spec/contracts';
12
-
13
- // Suppress logger output in tests
14
- const silentLogger = {
15
- info: vi.fn(),
16
- debug: vi.fn(),
17
- warn: vi.fn(),
18
- error: vi.fn(),
19
- child: vi.fn().mockReturnThis(),
20
- } as any;
21
-
22
- // ─────────────────────────────────────────────────────────────────
23
- // MemoryLLMAdapter
24
- // ─────────────────────────────────────────────────────────────────
25
-
26
- describe('MemoryLLMAdapter', () => {
27
- let adapter: MemoryLLMAdapter;
28
-
29
- beforeEach(() => {
30
- adapter = new MemoryLLMAdapter();
31
- });
32
-
33
- it('should have name "memory"', () => {
34
- expect(adapter.name).toBe('memory');
35
- });
36
-
37
- it('should echo the last user message in chat()', async () => {
38
- const messages: ModelMessage[] = [
39
- { role: 'system', content: 'You are helpful.' },
40
- { role: 'user', content: 'Hello AI' },
41
- ];
42
- const result = await adapter.chat(messages);
43
- expect(result.content).toBe('[memory] Hello AI');
44
- expect(result.model).toBe('memory');
45
- expect(result.usage).toBeDefined();
46
- });
47
-
48
- it('should handle no user message in chat()', async () => {
49
- const messages: ModelMessage[] = [{ role: 'system', content: 'System only' }];
50
- const result = await adapter.chat(messages);
51
- expect(result.content).toBe('[memory] (no user message)');
52
- });
53
-
54
- it('should echo prompt in complete()', async () => {
55
- const result = await adapter.complete('test prompt');
56
- expect(result.content).toBe('[memory] test prompt');
57
- });
58
-
59
- it('should stream word-by-word in streamChat()', async () => {
60
- const messages: ModelMessage[] = [{ role: 'user', content: 'Hi there' }];
61
- const events: TextStreamPart<ToolSet>[] = [];
62
- for await (const event of adapter.streamChat(messages)) {
63
- events.push(event);
64
- }
65
- // "[memory]" + " Hi" + " there" = 3 text-delta events + 1 finish
66
- expect(events.filter(e => e.type === 'text-delta').length).toBeGreaterThan(0);
67
- expect(events[events.length - 1].type).toBe('finish');
68
- });
69
-
70
- it('should return zero vectors for embed()', async () => {
71
- const result = await adapter.embed(['hello', 'world']);
72
- expect(result).toHaveLength(2);
73
- expect(result[0]).toEqual([0, 0, 0]);
74
- });
75
-
76
- it('should list memory model', async () => {
77
- const models = await adapter.listModels();
78
- expect(models).toEqual(['memory']);
79
- });
80
- });
81
-
82
- // ─────────────────────────────────────────────────────────────────
83
- // ToolRegistry
84
- // ─────────────────────────────────────────────────────────────────
85
-
86
- describe('ToolRegistry', () => {
87
- let registry: ToolRegistry;
88
-
89
- beforeEach(() => {
90
- registry = new ToolRegistry();
91
- });
92
-
93
- it('should register and retrieve a tool', () => {
94
- const def = { name: 'test_tool', description: 'A test', parameters: {} };
95
- registry.register(def, async () => 'result');
96
- expect(registry.has('test_tool')).toBe(true);
97
- expect(registry.getDefinition('test_tool')).toEqual(def);
98
- expect(registry.size).toBe(1);
99
- expect(registry.names()).toEqual(['test_tool']);
100
- });
101
-
102
- it('should unregister a tool', () => {
103
- registry.register({ name: 'tool_a', description: 'A', parameters: {} }, async () => '');
104
- registry.unregister('tool_a');
105
- expect(registry.has('tool_a')).toBe(false);
106
- expect(registry.size).toBe(0);
107
- });
108
-
109
- it('should execute a tool call', async () => {
110
- registry.register(
111
- { name: 'add', description: 'Add numbers', parameters: {} },
112
- async (args) => String((args.a as number) + (args.b as number)),
113
- );
114
-
115
- const result = await registry.execute({
116
- type: 'tool-call',
117
- toolCallId: 'call_1',
118
- toolName: 'add',
119
- input: { a: 3, b: 4 },
120
- });
121
-
122
- expect(result.toolCallId).toBe('call_1');
123
- expect(result.output).toEqual({ type: 'text', value: '7' });
124
- expect(result.isError).toBeUndefined();
125
- });
126
-
127
- it('should return error for unknown tool', async () => {
128
- const result = await registry.execute({
129
- type: 'tool-call',
130
- toolCallId: 'call_x',
131
- toolName: 'unknown',
132
- input: {},
133
- });
134
- expect(result.isError).toBe(true);
135
- expect(result.output).toEqual(expect.objectContaining({ type: 'text', value: expect.stringContaining('not registered') }));
136
- });
137
-
138
- it('should return error on handler failure', async () => {
139
- registry.register(
140
- { name: 'fail_tool', description: 'Fails', parameters: {} },
141
- async () => { throw new Error('boom'); },
142
- );
143
-
144
- const result = await registry.execute({
145
- type: 'tool-call',
146
- toolCallId: 'call_f',
147
- toolName: 'fail_tool',
148
- input: {},
149
- });
150
- expect(result.isError).toBe(true);
151
- expect(result.output).toEqual({ type: 'text', value: 'boom' });
152
- });
153
-
154
- it('should execute multiple tool calls in parallel', async () => {
155
- registry.register(
156
- { name: 'echo', description: 'Echo', parameters: {} },
157
- async (args) => args.msg as string,
158
- );
159
-
160
- const results = await registry.executeAll([
161
- { type: 'tool-call', toolCallId: 'c1', toolName: 'echo', input: { msg: 'a' } },
162
- { type: 'tool-call', toolCallId: 'c2', toolName: 'echo', input: { msg: 'b' } },
163
- ]);
164
-
165
- expect(results).toHaveLength(2);
166
- expect(results[0].output).toEqual({ type: 'text', value: 'a' });
167
- expect(results[1].output).toEqual({ type: 'text', value: 'b' });
168
- });
169
-
170
- it('should return all definitions', () => {
171
- registry.register({ name: 't1', description: 'T1', parameters: {} }, async () => '');
172
- registry.register({ name: 't2', description: 'T2', parameters: {} }, async () => '');
173
- expect(registry.getAll()).toHaveLength(2);
174
- });
175
-
176
- it('should clear all tools', () => {
177
- registry.register({ name: 'x', description: 'X', parameters: {} }, async () => '');
178
- registry.clear();
179
- expect(registry.size).toBe(0);
180
- });
181
- });
182
-
183
- // ─────────────────────────────────────────────────────────────────
184
- // InMemoryConversationService
185
- // ─────────────────────────────────────────────────────────────────
186
-
187
- describe('InMemoryConversationService', () => {
188
- let svc: InMemoryConversationService;
189
-
190
- beforeEach(() => {
191
- svc = new InMemoryConversationService();
192
- });
193
-
194
- it('should create a conversation', async () => {
195
- const conv = await svc.create({ title: 'Test', userId: 'u1' });
196
- expect(conv.id).toBeDefined();
197
- expect(conv.title).toBe('Test');
198
- expect(conv.userId).toBe('u1');
199
- expect(conv.messages).toHaveLength(0);
200
- expect(conv.createdAt).toBeDefined();
201
- });
202
-
203
- it('should get a conversation by ID', async () => {
204
- const created = await svc.create({ title: 'Lookup' });
205
- const found = await svc.get(created.id);
206
- expect(found).not.toBeNull();
207
- expect(found!.id).toBe(created.id);
208
-
209
- const missing = await svc.get('nonexistent');
210
- expect(missing).toBeNull();
211
- });
212
-
213
- it('should list conversations with filters', async () => {
214
- await svc.create({ userId: 'a', agentId: 'ag1' });
215
- await svc.create({ userId: 'b', agentId: 'ag1' });
216
- await svc.create({ userId: 'a', agentId: 'ag2' });
217
-
218
- expect((await svc.list()).length).toBe(3);
219
- expect((await svc.list({ userId: 'a' })).length).toBe(2);
220
- expect((await svc.list({ agentId: 'ag1' })).length).toBe(2);
221
- expect((await svc.list({ limit: 1 })).length).toBe(1);
222
- });
223
-
224
- it('should add messages to a conversation', async () => {
225
- const conv = await svc.create({});
226
- await svc.addMessage(conv.id, { role: 'user', content: 'Hi' });
227
- const updated = await svc.addMessage(conv.id, { role: 'assistant', content: 'Hello!' });
228
- expect(updated.messages).toHaveLength(2);
229
- });
230
-
231
- it('should throw when adding message to non-existent conversation', async () => {
232
- await expect(
233
- svc.addMessage('nope', { role: 'user', content: 'Hi' }),
234
- ).rejects.toThrow('not found');
235
- });
236
-
237
- it('should delete a conversation', async () => {
238
- const conv = await svc.create({});
239
- await svc.delete(conv.id);
240
- expect(await svc.get(conv.id)).toBeNull();
241
- });
242
-
243
- it('should track size', async () => {
244
- expect(svc.size).toBe(0);
245
- await svc.create({});
246
- expect(svc.size).toBe(1);
247
- });
248
-
249
- it('should clear all conversations', async () => {
250
- await svc.create({});
251
- await svc.create({});
252
- svc.clear();
253
- expect(svc.size).toBe(0);
254
- });
255
- });
256
-
257
- // ─────────────────────────────────────────────────────────────────
258
- // AIService (Orchestrator)
259
- // ─────────────────────────────────────────────────────────────────
260
-
261
- describe('AIService', () => {
262
- it('should use MemoryLLMAdapter by default', async () => {
263
- const service = new AIService({ logger: silentLogger });
264
- expect(service.adapterName).toBe('memory');
265
-
266
- const result = await service.chat([{ role: 'user', content: 'Hi' }]);
267
- expect(result.content).toBe('[memory] Hi');
268
- });
269
-
270
- it('should delegate complete() to adapter', async () => {
271
- const service = new AIService({ logger: silentLogger });
272
- const result = await service.complete('test');
273
- expect(result.content).toBe('[memory] test');
274
- });
275
-
276
- it('should stream via adapter.streamChat()', async () => {
277
- const service = new AIService({ logger: silentLogger });
278
- const events: TextStreamPart<ToolSet>[] = [];
279
- for await (const event of service.streamChat([{ role: 'user', content: 'Hi' }])) {
280
- events.push(event);
281
- }
282
- expect(events.length).toBeGreaterThan(1);
283
- expect(events[events.length - 1].type).toBe('finish');
284
- });
285
-
286
- it('should fall back to non-streaming when adapter has no streamChat', async () => {
287
- const adapter: LLMAdapter = {
288
- name: 'no-stream',
289
- chat: async () => ({ content: 'response', model: 'test' }),
290
- complete: async () => ({ content: '' }),
291
- // no streamChat
292
- };
293
- const service = new AIService({ adapter, logger: silentLogger });
294
-
295
- const events: TextStreamPart<ToolSet>[] = [];
296
- for await (const event of service.streamChat([{ role: 'user', content: 'Hi' }])) {
297
- events.push(event);
298
- }
299
-
300
- expect(events).toHaveLength(2);
301
- expect(events[0].type).toBe('text-delta');
302
- expect(events[0].type === 'text-delta' && events[0].text).toBe('response');
303
- expect(events[1].type).toBe('finish');
304
- });
305
-
306
- it('should delegate embed() to adapter', async () => {
307
- const service = new AIService({ logger: silentLogger });
308
- const embeddings = await service.embed('hello');
309
- expect(embeddings).toHaveLength(1);
310
- });
311
-
312
- it('should throw when adapter does not support embed()', async () => {
313
- const adapter: LLMAdapter = {
314
- name: 'no-embed',
315
- chat: async () => ({ content: '' }),
316
- complete: async () => ({ content: '' }),
317
- };
318
- const service = new AIService({ adapter, logger: silentLogger });
319
- await expect(service.embed('hello')).rejects.toThrow('does not support embeddings');
320
- });
321
-
322
- it('should delegate listModels() to adapter', async () => {
323
- const service = new AIService({ logger: silentLogger });
324
- const models = await service.listModels();
325
- expect(models).toEqual(['memory']);
326
- });
327
-
328
- it('should return empty array when adapter has no listModels()', async () => {
329
- const adapter: LLMAdapter = {
330
- name: 'no-models',
331
- chat: async () => ({ content: '' }),
332
- complete: async () => ({ content: '' }),
333
- };
334
- const service = new AIService({ adapter, logger: silentLogger });
335
- const models = await service.listModels();
336
- expect(models).toEqual([]);
337
- });
338
-
339
- it('should expose toolRegistry and conversationService', () => {
340
- const service = new AIService({ logger: silentLogger });
341
- expect(service.toolRegistry).toBeInstanceOf(ToolRegistry);
342
- expect(service.conversationService).toBeInstanceOf(InMemoryConversationService);
343
- });
344
-
345
- it('should accept custom adapter', async () => {
346
- const customAdapter: LLMAdapter = {
347
- name: 'custom',
348
- chat: async () => ({ content: 'custom response' }),
349
- complete: async (p) => ({ content: `custom: ${p}` }),
350
- };
351
- const service = new AIService({ adapter: customAdapter, logger: silentLogger });
352
- expect(service.adapterName).toBe('custom');
353
-
354
- const result = await service.chat([{ role: 'user', content: 'test' }]);
355
- expect(result.content).toBe('custom response');
356
- });
357
- });
358
-
359
- // ─────────────────────────────────────────────────────────────────
360
- // Routes
361
- // ─────────────────────────────────────────────────────────────────
362
-
363
- describe('AI Routes', () => {
364
- let service: AIService;
365
-
366
- beforeEach(() => {
367
- service = new AIService({ logger: silentLogger });
368
- });
369
-
370
- it('should build all expected routes', () => {
371
- const routes = buildAIRoutes(service, service.conversationService, silentLogger);
372
- expect(routes.length).toBe(8);
373
-
374
- const paths = routes.map(r => `${r.method} ${r.path}`);
375
- expect(paths).toContain('POST /api/v1/ai/chat');
376
- expect(paths).toContain('POST /api/v1/ai/chat/stream');
377
- expect(paths).toContain('POST /api/v1/ai/complete');
378
- expect(paths).toContain('GET /api/v1/ai/models');
379
- expect(paths).toContain('POST /api/v1/ai/conversations');
380
- expect(paths).toContain('GET /api/v1/ai/conversations');
381
- expect(paths).toContain('POST /api/v1/ai/conversations/:id/messages');
382
- expect(paths).toContain('DELETE /api/v1/ai/conversations/:id');
383
- });
384
-
385
- it('POST /api/v1/ai/chat should return JSON result when stream=false', async () => {
386
- const routes = buildAIRoutes(service, service.conversationService, silentLogger);
387
- const chatRoute = routes.find(r => r.path === '/api/v1/ai/chat')!;
388
-
389
- const response = await chatRoute.handler({
390
- body: { messages: [{ role: 'user', content: 'Hi' }], stream: false },
391
- });
392
-
393
- expect(response.status).toBe(200);
394
- expect((response.body as any).content).toBe('[memory] Hi');
395
- });
396
-
397
- it('POST /api/v1/ai/chat should default to Vercel Data Stream mode', async () => {
398
- const routes = buildAIRoutes(service, service.conversationService, silentLogger);
399
- const chatRoute = routes.find(r => r.path === '/api/v1/ai/chat')!;
400
-
401
- const response = await chatRoute.handler({
402
- body: { messages: [{ role: 'user', content: 'Hi' }] },
403
- });
404
-
405
- expect(response.status).toBe(200);
406
- expect(response.stream).toBe(true);
407
- expect(response.vercelDataStream).toBe(true);
408
- expect(response.events).toBeDefined();
409
-
410
- // Consume the Vercel Data Stream events
411
- const events: unknown[] = [];
412
- for await (const event of response.events!) {
413
- events.push(event);
414
- }
415
- expect(events.length).toBeGreaterThan(0);
416
- });
417
-
418
- it('POST /api/v1/ai/chat should prepend systemPrompt as system message', async () => {
419
- const routes = buildAIRoutes(service, service.conversationService, silentLogger);
420
- const chatRoute = routes.find(r => r.path === '/api/v1/ai/chat')!;
421
-
422
- const response = await chatRoute.handler({
423
- body: {
424
- messages: [{ role: 'user', content: 'Hello' }],
425
- system: 'You are a helpful assistant',
426
- stream: false,
427
- },
428
- });
429
-
430
- expect(response.status).toBe(200);
431
- // MemoryLLMAdapter echoes the last user message
432
- expect((response.body as any).content).toBe('[memory] Hello');
433
- });
434
-
435
- it('POST /api/v1/ai/chat should accept deprecated systemPrompt field', async () => {
436
- const routes = buildAIRoutes(service, service.conversationService, silentLogger);
437
- const chatRoute = routes.find(r => r.path === '/api/v1/ai/chat')!;
438
-
439
- const response = await chatRoute.handler({
440
- body: {
441
- messages: [{ role: 'user', content: 'Hi' }],
442
- systemPrompt: 'Be concise',
443
- stream: false,
444
- },
445
- });
446
-
447
- expect(response.status).toBe(200);
448
- expect((response.body as any).content).toBe('[memory] Hi');
449
- });
450
-
451
- it('POST /api/v1/ai/chat should accept flat Vercel-style fields (model, temperature)', async () => {
452
- const routes = buildAIRoutes(service, service.conversationService, silentLogger);
453
- const chatRoute = routes.find(r => r.path === '/api/v1/ai/chat')!;
454
-
455
- const response = await chatRoute.handler({
456
- body: {
457
- messages: [{ role: 'user', content: 'Hi' }],
458
- model: 'gpt-4o',
459
- temperature: 0.5,
460
- stream: false,
461
- },
462
- });
463
-
464
- expect(response.status).toBe(200);
465
- // MemoryLLMAdapter uses the model from options when provided
466
- expect((response.body as any).model).toBe('gpt-4o');
467
- });
468
-
469
- it('POST /api/v1/ai/chat should accept array content (Vercel multi-part)', async () => {
470
- const routes = buildAIRoutes(service, service.conversationService, silentLogger);
471
- const chatRoute = routes.find(r => r.path === '/api/v1/ai/chat')!;
472
-
473
- const response = await chatRoute.handler({
474
- body: {
475
- messages: [{ role: 'user', content: [{ type: 'text', text: 'Hi' }] }],
476
- stream: false,
477
- },
478
- });
479
-
480
- // MemoryLLMAdapter falls back to "(complex content)" for non-string
481
- expect(response.status).toBe(200);
482
- expect((response.body as any).content).toBe('[memory] (complex content)');
483
- });
484
-
485
- it('POST /api/v1/ai/chat should return 400 without messages', async () => {
486
- const routes = buildAIRoutes(service, service.conversationService, silentLogger);
487
- const chatRoute = routes.find(r => r.path === '/api/v1/ai/chat')!;
488
-
489
- const response = await chatRoute.handler({ body: {} });
490
- expect(response.status).toBe(400);
491
- });
492
-
493
- it('POST /api/v1/ai/chat/stream should return streaming response', async () => {
494
- const routes = buildAIRoutes(service, service.conversationService, silentLogger);
495
- const streamRoute = routes.find(r => r.path === '/api/v1/ai/chat/stream')!;
496
-
497
- const response = await streamRoute.handler({
498
- body: { messages: [{ role: 'user', content: 'Hello' }] },
499
- });
500
-
501
- expect(response.status).toBe(200);
502
- expect(response.stream).toBe(true);
503
- expect(response.events).toBeDefined();
504
-
505
- // Consume the stream
506
- const events: unknown[] = [];
507
- for await (const event of response.events!) {
508
- events.push(event);
509
- }
510
- expect(events.length).toBeGreaterThan(0);
511
- });
512
-
513
- it('POST /api/v1/ai/complete should return completion result', async () => {
514
- const routes = buildAIRoutes(service, service.conversationService, silentLogger);
515
- const completeRoute = routes.find(r => r.path === '/api/v1/ai/complete')!;
516
-
517
- const response = await completeRoute.handler({
518
- body: { prompt: 'test prompt' },
519
- });
520
-
521
- expect(response.status).toBe(200);
522
- expect((response.body as any).content).toBe('[memory] test prompt');
523
- });
524
-
525
- it('POST /api/v1/ai/complete should return 400 without prompt', async () => {
526
- const routes = buildAIRoutes(service, service.conversationService, silentLogger);
527
- const completeRoute = routes.find(r => r.path === '/api/v1/ai/complete')!;
528
-
529
- const response = await completeRoute.handler({ body: {} });
530
- expect(response.status).toBe(400);
531
- });
532
-
533
- it('GET /api/v1/ai/models should return model list', async () => {
534
- const routes = buildAIRoutes(service, service.conversationService, silentLogger);
535
- const modelsRoute = routes.find(r => r.path === '/api/v1/ai/models')!;
536
-
537
- const response = await modelsRoute.handler({});
538
- expect(response.status).toBe(200);
539
- expect((response.body as any).models).toContain('memory');
540
- });
541
-
542
- it('POST /api/v1/ai/conversations should create conversation', async () => {
543
- const routes = buildAIRoutes(service, service.conversationService, silentLogger);
544
- const createRoute = routes.find(r => r.method === 'POST' && r.path === '/api/v1/ai/conversations')!;
545
-
546
- const response = await createRoute.handler({
547
- body: { title: 'Test Conv', userId: 'u1' },
548
- });
549
-
550
- expect(response.status).toBe(201);
551
- expect((response.body as any).title).toBe('Test Conv');
552
- });
553
-
554
- it('GET /api/v1/ai/conversations should list conversations', async () => {
555
- const routes = buildAIRoutes(service, service.conversationService, silentLogger);
556
- const createRoute = routes.find(r => r.method === 'POST' && r.path === '/api/v1/ai/conversations')!;
557
- const listRoute = routes.find(r => r.method === 'GET' && r.path === '/api/v1/ai/conversations')!;
558
-
559
- await createRoute.handler({ body: { title: 'C1' } });
560
- await createRoute.handler({ body: { title: 'C2' } });
561
-
562
- const response = await listRoute.handler({});
563
- expect(response.status).toBe(200);
564
- expect((response.body as any).conversations).toHaveLength(2);
565
- });
566
-
567
- it('POST /api/v1/ai/conversations/:id/messages should add message', async () => {
568
- const routes = buildAIRoutes(service, service.conversationService, silentLogger);
569
- const createRoute = routes.find(r => r.method === 'POST' && r.path === '/api/v1/ai/conversations')!;
570
- const addMsgRoute = routes.find(r => r.path === '/api/v1/ai/conversations/:id/messages')!;
571
-
572
- const created = await createRoute.handler({ body: {} });
573
- const convId = (created.body as any).id;
574
-
575
- const response = await addMsgRoute.handler({
576
- params: { id: convId },
577
- body: { role: 'user', content: 'Hi there' },
578
- });
579
-
580
- expect(response.status).toBe(200);
581
- expect((response.body as any).messages).toHaveLength(1);
582
- });
583
-
584
- it('POST /api/v1/ai/conversations/:id/messages should return 404 for unknown conversation', async () => {
585
- const routes = buildAIRoutes(service, service.conversationService, silentLogger);
586
- const addMsgRoute = routes.find(r => r.path === '/api/v1/ai/conversations/:id/messages')!;
587
-
588
- const response = await addMsgRoute.handler({
589
- params: { id: 'unknown' },
590
- body: { role: 'user', content: 'Hi' },
591
- });
592
-
593
- expect(response.status).toBe(404);
594
- });
595
-
596
- it('DELETE /api/v1/ai/conversations/:id should delete conversation', async () => {
597
- const routes = buildAIRoutes(service, service.conversationService, silentLogger);
598
- const createRoute = routes.find(r => r.method === 'POST' && r.path === '/api/v1/ai/conversations')!;
599
- const deleteRoute = routes.find(r => r.path === '/api/v1/ai/conversations/:id')!;
600
-
601
- const created = await createRoute.handler({ body: {} });
602
- const convId = (created.body as any).id;
603
-
604
- const response = await deleteRoute.handler({ params: { id: convId } });
605
- expect(response.status).toBe(204);
606
- });
607
-
608
- // ── Message validation ───────────────────────────────────────
609
-
610
- it('POST /api/v1/ai/chat should return 400 for messages with invalid role', async () => {
611
- const routes = buildAIRoutes(service, service.conversationService, silentLogger);
612
- const chatRoute = routes.find(r => r.path === '/api/v1/ai/chat')!;
613
-
614
- const response = await chatRoute.handler({
615
- body: { messages: [{ role: 'invalid', content: 'Hi' }] },
616
- });
617
-
618
- expect(response.status).toBe(400);
619
- expect((response.body as any).error).toContain('message.role');
620
- });
621
-
622
- it('POST /api/v1/ai/chat should return 400 for messages with non-string/non-array content', async () => {
623
- const routes = buildAIRoutes(service, service.conversationService, silentLogger);
624
- const chatRoute = routes.find(r => r.path === '/api/v1/ai/chat')!;
625
-
626
- // Numeric content should be rejected
627
- const response = await chatRoute.handler({
628
- body: { messages: [{ role: 'user', content: 123 }] },
629
- });
630
- expect(response.status).toBe(400);
631
- expect((response.body as any).error).toContain('content');
632
-
633
- // Object content (not an array) should be rejected
634
- const response2 = await chatRoute.handler({
635
- body: { messages: [{ role: 'user', content: { nested: true } }] },
636
- });
637
- expect(response2.status).toBe(400);
638
- expect((response2.body as any).error).toContain('content');
639
-
640
- // Boolean content should be rejected
641
- const response3 = await chatRoute.handler({
642
- body: { messages: [{ role: 'user', content: true }] },
643
- });
644
- expect(response3.status).toBe(400);
645
- expect((response3.body as any).error).toContain('content');
646
- });
647
-
648
- it('POST /api/v1/ai/conversations/:id/messages should return 400 for invalid role', async () => {
649
- const routes = buildAIRoutes(service, service.conversationService, silentLogger);
650
- const createRoute = routes.find(r => r.method === 'POST' && r.path === '/api/v1/ai/conversations')!;
651
- const addMsgRoute = routes.find(r => r.path === '/api/v1/ai/conversations/:id/messages')!;
652
-
653
- const created = await createRoute.handler({ body: {} });
654
- const convId = (created.body as any).id;
655
-
656
- const response = await addMsgRoute.handler({
657
- params: { id: convId },
658
- body: { role: 'invalid_role', content: 'Hi' },
659
- });
660
-
661
- expect(response.status).toBe(400);
662
- expect((response.body as any).error).toContain('message.role');
663
- });
664
-
665
- it('POST /api/v1/ai/conversations/:id/messages should return 400 for missing content', async () => {
666
- const routes = buildAIRoutes(service, service.conversationService, silentLogger);
667
- const addMsgRoute = routes.find(r => r.path === '/api/v1/ai/conversations/:id/messages')!;
668
-
669
- const response = await addMsgRoute.handler({
670
- params: { id: 'conv_1' },
671
- body: { role: 'user' },
672
- });
673
-
674
- expect(response.status).toBe(400);
675
- expect((response.body as any).error).toContain('content');
676
- });
677
-
678
- // ── Limit parsing ───────────────────────────────────────────
679
-
680
- it('GET /api/v1/ai/conversations should parse limit from query string', async () => {
681
- const routes = buildAIRoutes(service, service.conversationService, silentLogger);
682
- const createRoute = routes.find(r => r.method === 'POST' && r.path === '/api/v1/ai/conversations')!;
683
- const listRoute = routes.find(r => r.method === 'GET' && r.path === '/api/v1/ai/conversations')!;
684
-
685
- await createRoute.handler({ body: { title: 'C1' } });
686
- await createRoute.handler({ body: { title: 'C2' } });
687
- await createRoute.handler({ body: { title: 'C3' } });
688
-
689
- const response = await listRoute.handler({ query: { limit: '2' } });
690
- expect(response.status).toBe(200);
691
- expect((response.body as any).conversations).toHaveLength(2);
692
- });
693
-
694
- it('GET /api/v1/ai/conversations should return 400 for invalid limit', async () => {
695
- const routes = buildAIRoutes(service, service.conversationService, silentLogger);
696
- const listRoute = routes.find(r => r.method === 'GET' && r.path === '/api/v1/ai/conversations')!;
697
-
698
- const response = await listRoute.handler({ query: { limit: 'abc' } });
699
- expect(response.status).toBe(400);
700
- expect((response.body as any).error).toContain('limit');
701
- });
702
-
703
- it('GET /api/v1/ai/conversations should return 400 for negative limit', async () => {
704
- const routes = buildAIRoutes(service, service.conversationService, silentLogger);
705
- const listRoute = routes.find(r => r.method === 'GET' && r.path === '/api/v1/ai/conversations')!;
706
-
707
- const response = await listRoute.handler({ query: { limit: '-1' } });
708
- expect(response.status).toBe(400);
709
- expect((response.body as any).error).toContain('limit');
710
- });
711
-
712
- // ── Tool message in chat ────────────────────────────────────
713
-
714
- it('POST /api/v1/ai/chat should accept tool role messages', async () => {
715
- const routes = buildAIRoutes(service, service.conversationService, silentLogger);
716
- const chatRoute = routes.find(r => r.path === '/api/v1/ai/chat')!;
717
-
718
- const response = await chatRoute.handler({
719
- body: {
720
- messages: [
721
- { role: 'user', content: 'What is the weather?' },
722
- { role: 'assistant', content: '' },
723
- { role: 'tool', content: '{"temp": 22}', toolCallId: 'call_1' },
724
- ],
725
- stream: false,
726
- },
727
- });
728
-
729
- expect(response.status).toBe(200);
730
- });
731
- });
732
-
733
- // ─────────────────────────────────────────────────────────────────
734
- // AIServicePlugin (Integration)
735
- // ─────────────────────────────────────────────────────────────────
736
-
737
- describe('AIServicePlugin', () => {
738
- function createMockContext() {
739
- const services = new Map<string, unknown>();
740
- const hooks = new Map<string, Function[]>();
741
-
742
- // Pre-register manifest service
743
- services.set('manifest', { register: vi.fn() });
744
-
745
- return {
746
- registerService: vi.fn((name: string, service: unknown) => services.set(name, service)),
747
- replaceService: vi.fn((name: string, service: unknown) => services.set(name, service)),
748
- getService: vi.fn(<T>(name: string): T => {
749
- if (!services.has(name)) throw new Error(`Service "${name}" not found`);
750
- return services.get(name) as T;
751
- }),
752
- getServices: vi.fn(() => services),
753
- hook: vi.fn((name: string, handler: Function) => {
754
- if (!hooks.has(name)) hooks.set(name, []);
755
- hooks.get(name)!.push(handler);
756
- }),
757
- trigger: vi.fn(async () => {}),
758
- logger: silentLogger,
759
- getKernel: vi.fn(),
760
- } as any;
761
- }
762
-
763
- it('should register as "ai" service on init', async () => {
764
- const plugin = new AIServicePlugin();
765
- const ctx = createMockContext();
766
-
767
- await plugin.init(ctx);
768
-
769
- expect(ctx.registerService).toHaveBeenCalledWith('ai', expect.any(Object));
770
- const service = ctx.getService<IAIService>('ai');
771
- expect(service).toBeDefined();
772
- expect(typeof service.chat).toBe('function');
773
- });
774
-
775
- it('should have correct plugin metadata', () => {
776
- const plugin = new AIServicePlugin();
777
- expect(plugin.name).toBe('com.objectstack.service-ai');
778
- expect(plugin.version).toBe('1.0.0');
779
- expect(plugin.type).toBe('standard');
780
- });
781
-
782
- it('should trigger ai:ready on start', async () => {
783
- const plugin = new AIServicePlugin();
784
- const ctx = createMockContext();
785
-
786
- await plugin.init(ctx);
787
- await plugin.start!(ctx);
788
-
789
- expect(ctx.trigger).toHaveBeenCalledWith('ai:ready', expect.any(Object));
790
- expect(ctx.trigger).toHaveBeenCalledWith('ai:routes', expect.any(Array));
791
- });
792
-
793
- it('should use custom adapter when provided', async () => {
794
- const customAdapter: LLMAdapter = {
795
- name: 'custom-test',
796
- chat: async () => ({ content: 'custom' }),
797
- complete: async () => ({ content: '' }),
798
- };
799
-
800
- const plugin = new AIServicePlugin({ adapter: customAdapter });
801
- const ctx = createMockContext();
802
-
803
- await plugin.init(ctx);
804
-
805
- const service = ctx.getService<AIService>('ai');
806
- expect(service.adapterName).toBe('custom-test');
807
- });
808
-
809
- it('should replace existing AI service', async () => {
810
- const plugin = new AIServicePlugin();
811
- const ctx = createMockContext();
812
-
813
- // Pre-register a mock AI service
814
- ctx.registerService('ai', { chat: vi.fn(), complete: vi.fn() });
815
-
816
- await plugin.init(ctx);
817
-
818
- expect(ctx.replaceService).toHaveBeenCalledWith('ai', expect.any(Object));
819
- });
820
-
821
- it('should clean up on destroy', async () => {
822
- const plugin = new AIServicePlugin();
823
- const ctx = createMockContext();
824
-
825
- await plugin.init(ctx);
826
- await plugin.destroy!();
827
-
828
- // After destroy, the plugin should not throw
829
- // (internal service reference cleared)
830
- });
831
-
832
- it('should register debug hook when debug=true', async () => {
833
- const plugin = new AIServicePlugin({ debug: true });
834
- const ctx = createMockContext();
835
-
836
- await plugin.init(ctx);
837
-
838
- expect(ctx.hook).toHaveBeenCalledWith('ai:beforeChat', expect.any(Function));
839
- });
840
-
841
- // ── LLM Provider Auto-Detection ─────────────────────────────────
842
-
843
- it('should use MemoryLLMAdapter when no env vars are set', async () => {
844
- const plugin = new AIServicePlugin();
845
- const ctx = createMockContext();
846
-
847
- // Ensure no LLM provider env vars are set
848
- const oldEnv = { ...process.env };
849
- delete process.env.AI_GATEWAY_MODEL;
850
- delete process.env.OPENAI_API_KEY;
851
- delete process.env.ANTHROPIC_API_KEY;
852
- delete process.env.GOOGLE_GENERATIVE_AI_API_KEY;
853
-
854
- try {
855
- await plugin.init(ctx);
856
-
857
- const service = ctx.getService<AIService>('ai');
858
- expect(service.adapterName).toBe('memory');
859
-
860
- // Verify warning was logged
861
- expect(silentLogger.warn).toHaveBeenCalledWith(
862
- expect.stringContaining('No LLM provider configured')
863
- );
864
- } finally {
865
- // Restore environment
866
- process.env = oldEnv;
867
- }
868
- });
869
-
870
- it('should fallback to MemoryLLMAdapter when provider SDK is not installed', async () => {
871
- // Mock all provider SDKs to simulate them not being installed.
872
- // In the workspace @ai-sdk/openai may be resolvable as a transitive
873
- // dependency, so we must explicitly make the dynamic import fail.
874
- vi.doMock('@ai-sdk/openai', () => { throw new Error('Cannot find module \'@ai-sdk/openai\''); });
875
- vi.doMock('@ai-sdk/anthropic', () => { throw new Error('Cannot find module \'@ai-sdk/anthropic\''); });
876
- vi.doMock('@ai-sdk/google', () => { throw new Error('Cannot find module \'@ai-sdk/google\''); });
877
-
878
- // Re-import the plugin module so it picks up the mocked imports
879
- const { AIServicePlugin: FreshPlugin } = await import('../plugin.js');
880
- const plugin = new FreshPlugin();
881
- const ctx = createMockContext();
882
-
883
- const oldEnv = { ...process.env };
884
- // Set env var, but the SDK won't be available in test environment
885
- process.env.OPENAI_API_KEY = 'fake-openai-key';
886
- delete process.env.AI_GATEWAY_MODEL;
887
- delete process.env.ANTHROPIC_API_KEY;
888
- delete process.env.GOOGLE_GENERATIVE_AI_API_KEY;
889
-
890
- try {
891
- await plugin.init(ctx);
892
-
893
- const service = ctx.getService<AIService>('ai');
894
- // Should fall back to memory because @ai-sdk/openai is not installed
895
- expect(service.adapterName).toBe('memory');
896
-
897
- // Verify warning was logged about SDK load failure
898
- expect(silentLogger.warn).toHaveBeenCalledWith(
899
- expect.stringContaining('Failed to load @ai-sdk/openai'),
900
- expect.objectContaining({ error: expect.any(String) })
901
- );
902
-
903
- // Verify warning was logged about final fallback
904
- expect(silentLogger.warn).toHaveBeenCalledWith(
905
- expect.stringContaining('No LLM provider configured')
906
- );
907
- } finally {
908
- process.env = oldEnv;
909
- vi.doUnmock('@ai-sdk/openai');
910
- vi.doUnmock('@ai-sdk/anthropic');
911
- vi.doUnmock('@ai-sdk/google');
912
- }
913
- });
914
-
915
- it('should prefer explicit adapter over auto-detection', async () => {
916
- const customAdapter: LLMAdapter = {
917
- name: 'custom-explicit',
918
- chat: async () => ({ content: 'explicit' }),
919
- complete: async () => ({ content: '' }),
920
- };
921
-
922
- const plugin = new AIServicePlugin({ adapter: customAdapter });
923
- const ctx = createMockContext();
924
-
925
- const oldEnv = { ...process.env };
926
- process.env.OPENAI_API_KEY = 'fake-openai-key';
927
-
928
- try {
929
- await plugin.init(ctx);
930
-
931
- const service = ctx.getService<AIService>('ai');
932
- expect(service.adapterName).toBe('custom-explicit');
933
-
934
- // Verify it logged as explicitly configured
935
- expect(silentLogger.info).toHaveBeenCalledWith(
936
- expect.stringContaining('explicitly configured')
937
- );
938
- } finally {
939
- process.env = oldEnv;
940
- }
941
- });
942
-
943
- it('should log adapter selection', async () => {
944
- const plugin = new AIServicePlugin();
945
- const ctx = createMockContext();
946
-
947
- const oldEnv = { ...process.env };
948
- delete process.env.AI_GATEWAY_MODEL;
949
- delete process.env.OPENAI_API_KEY;
950
- delete process.env.ANTHROPIC_API_KEY;
951
- delete process.env.GOOGLE_GENERATIVE_AI_API_KEY;
952
-
953
- try {
954
- await plugin.init(ctx);
955
-
956
- // Verify adapter selection was logged
957
- expect(silentLogger.info).toHaveBeenCalledWith(
958
- expect.stringContaining('Using LLM adapter')
959
- );
960
- } finally {
961
- process.env = oldEnv;
962
- }
963
- });
964
- });