@objectstack/service-ai 4.0.0 → 4.0.2

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 (40) hide show
  1. package/.turbo/turbo-build.log +11 -11
  2. package/CHANGELOG.md +20 -0
  3. package/dist/index.cjs +1245 -54
  4. package/dist/index.cjs.map +1 -1
  5. package/dist/index.d.cts +344 -77
  6. package/dist/index.d.ts +344 -77
  7. package/dist/index.js +1230 -51
  8. package/dist/index.js.map +1 -1
  9. package/package.json +26 -4
  10. package/src/__tests__/ai-service.test.ts +248 -27
  11. package/src/__tests__/auth-and-toolcalling.test.ts +627 -0
  12. package/src/__tests__/chatbot-features.test.ts +229 -82
  13. package/src/__tests__/metadata-tools.test.ts +964 -0
  14. package/src/__tests__/objectql-conversation-service.test.ts +34 -16
  15. package/src/__tests__/vercel-stream-encoder.test.ts +263 -0
  16. package/src/adapters/index.ts +2 -0
  17. package/src/adapters/memory-adapter.ts +17 -9
  18. package/src/adapters/vercel-adapter.ts +148 -0
  19. package/src/agent-runtime.ts +27 -3
  20. package/src/agents/index.ts +1 -0
  21. package/src/agents/metadata-assistant-agent.ts +87 -0
  22. package/src/ai-service.ts +174 -22
  23. package/src/conversation/in-memory-conversation-service.ts +2 -2
  24. package/src/conversation/objectql-conversation-service.ts +67 -18
  25. package/src/index.ts +22 -3
  26. package/src/plugin.ts +166 -9
  27. package/src/routes/agent-routes.ts +28 -3
  28. package/src/routes/ai-routes.ts +231 -14
  29. package/src/routes/index.ts +1 -1
  30. package/src/stream/index.ts +3 -0
  31. package/src/stream/vercel-stream-encoder.ts +129 -0
  32. package/src/tools/add-field.tool.ts +70 -0
  33. package/src/tools/create-object.tool.ts +66 -0
  34. package/src/tools/delete-field.tool.ts +38 -0
  35. package/src/tools/describe-metadata-object.tool.ts +32 -0
  36. package/src/tools/index.ts +12 -1
  37. package/src/tools/list-metadata-objects.tool.ts +34 -0
  38. package/src/tools/metadata-tools.ts +430 -0
  39. package/src/tools/modify-field.tool.ts +44 -0
  40. package/src/tools/tool-registry.ts +32 -9
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@objectstack/service-ai",
3
- "version": "4.0.0",
3
+ "version": "4.0.2",
4
4
  "license": "Apache-2.0",
5
5
  "description": "AI Service for ObjectStack — implements IAIService with LLM adapter layer, conversation management, tool registry, and REST/SSE routes",
6
6
  "type": "module",
@@ -14,11 +14,33 @@
14
14
  }
15
15
  },
16
16
  "dependencies": {
17
- "@objectstack/core": "4.0.0",
18
- "@objectstack/spec": "4.0.0"
17
+ "@ai-sdk/provider": "^3.0.8",
18
+ "ai": "^6.0.146",
19
+ "@objectstack/core": "4.0.2",
20
+ "@objectstack/spec": "4.0.2"
21
+ },
22
+ "peerDependencies": {
23
+ "@ai-sdk/anthropic": "^3.0.0",
24
+ "@ai-sdk/gateway": "^3.0.0",
25
+ "@ai-sdk/google": "^3.0.0",
26
+ "@ai-sdk/openai": "^3.0.0"
27
+ },
28
+ "peerDependenciesMeta": {
29
+ "@ai-sdk/anthropic": {
30
+ "optional": true
31
+ },
32
+ "@ai-sdk/gateway": {
33
+ "optional": true
34
+ },
35
+ "@ai-sdk/google": {
36
+ "optional": true
37
+ },
38
+ "@ai-sdk/openai": {
39
+ "optional": true
40
+ }
19
41
  },
20
42
  "devDependencies": {
21
- "@types/node": "^25.5.0",
43
+ "@types/node": "^25.5.2",
22
44
  "typescript": "^6.0.2",
23
45
  "vitest": "^4.1.2"
24
46
  },
@@ -1,7 +1,7 @@
1
1
  // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2
2
 
3
3
  import { describe, it, expect, vi, beforeEach } from 'vitest';
4
- import type { AIMessage, IAIService, AIStreamEvent } from '@objectstack/spec/contracts';
4
+ import type { ModelMessage, IAIService, TextStreamPart, ToolSet } from '@objectstack/spec/contracts';
5
5
  import { AIService } from '../ai-service.js';
6
6
  import { MemoryLLMAdapter } from '../adapters/memory-adapter.js';
7
7
  import { ToolRegistry } from '../tools/tool-registry.js';
@@ -35,7 +35,7 @@ describe('MemoryLLMAdapter', () => {
35
35
  });
36
36
 
37
37
  it('should echo the last user message in chat()', async () => {
38
- const messages: AIMessage[] = [
38
+ const messages: ModelMessage[] = [
39
39
  { role: 'system', content: 'You are helpful.' },
40
40
  { role: 'user', content: 'Hello AI' },
41
41
  ];
@@ -46,7 +46,7 @@ describe('MemoryLLMAdapter', () => {
46
46
  });
47
47
 
48
48
  it('should handle no user message in chat()', async () => {
49
- const messages: AIMessage[] = [{ role: 'system', content: 'System only' }];
49
+ const messages: ModelMessage[] = [{ role: 'system', content: 'System only' }];
50
50
  const result = await adapter.chat(messages);
51
51
  expect(result.content).toBe('[memory] (no user message)');
52
52
  });
@@ -57,8 +57,8 @@ describe('MemoryLLMAdapter', () => {
57
57
  });
58
58
 
59
59
  it('should stream word-by-word in streamChat()', async () => {
60
- const messages: AIMessage[] = [{ role: 'user', content: 'Hi there' }];
61
- const events: AIStreamEvent[] = [];
60
+ const messages: ModelMessage[] = [{ role: 'user', content: 'Hi there' }];
61
+ const events: TextStreamPart<ToolSet>[] = [];
62
62
  for await (const event of adapter.streamChat(messages)) {
63
63
  events.push(event);
64
64
  }
@@ -113,24 +113,26 @@ describe('ToolRegistry', () => {
113
113
  );
114
114
 
115
115
  const result = await registry.execute({
116
- id: 'call_1',
117
- name: 'add',
118
- arguments: JSON.stringify({ a: 3, b: 4 }),
116
+ type: 'tool-call',
117
+ toolCallId: 'call_1',
118
+ toolName: 'add',
119
+ input: { a: 3, b: 4 },
119
120
  });
120
121
 
121
122
  expect(result.toolCallId).toBe('call_1');
122
- expect(result.content).toBe('7');
123
+ expect(result.output).toEqual({ type: 'text', value: '7' });
123
124
  expect(result.isError).toBeUndefined();
124
125
  });
125
126
 
126
127
  it('should return error for unknown tool', async () => {
127
128
  const result = await registry.execute({
128
- id: 'call_x',
129
- name: 'unknown',
130
- arguments: '{}',
129
+ type: 'tool-call',
130
+ toolCallId: 'call_x',
131
+ toolName: 'unknown',
132
+ input: {},
131
133
  });
132
134
  expect(result.isError).toBe(true);
133
- expect(result.content).toContain('not registered');
135
+ expect(result.output).toEqual(expect.objectContaining({ type: 'text', value: expect.stringContaining('not registered') }));
134
136
  });
135
137
 
136
138
  it('should return error on handler failure', async () => {
@@ -140,12 +142,13 @@ describe('ToolRegistry', () => {
140
142
  );
141
143
 
142
144
  const result = await registry.execute({
143
- id: 'call_f',
144
- name: 'fail_tool',
145
- arguments: '{}',
145
+ type: 'tool-call',
146
+ toolCallId: 'call_f',
147
+ toolName: 'fail_tool',
148
+ input: {},
146
149
  });
147
150
  expect(result.isError).toBe(true);
148
- expect(result.content).toBe('boom');
151
+ expect(result.output).toEqual({ type: 'text', value: 'boom' });
149
152
  });
150
153
 
151
154
  it('should execute multiple tool calls in parallel', async () => {
@@ -155,13 +158,13 @@ describe('ToolRegistry', () => {
155
158
  );
156
159
 
157
160
  const results = await registry.executeAll([
158
- { id: 'c1', name: 'echo', arguments: '{"msg":"a"}' },
159
- { id: 'c2', name: 'echo', arguments: '{"msg":"b"}' },
161
+ { type: 'tool-call', toolCallId: 'c1', toolName: 'echo', input: { msg: 'a' } },
162
+ { type: 'tool-call', toolCallId: 'c2', toolName: 'echo', input: { msg: 'b' } },
160
163
  ]);
161
164
 
162
165
  expect(results).toHaveLength(2);
163
- expect(results[0].content).toBe('a');
164
- expect(results[1].content).toBe('b');
166
+ expect(results[0].output).toEqual({ type: 'text', value: 'a' });
167
+ expect(results[1].output).toEqual({ type: 'text', value: 'b' });
165
168
  });
166
169
 
167
170
  it('should return all definitions', () => {
@@ -272,7 +275,7 @@ describe('AIService', () => {
272
275
 
273
276
  it('should stream via adapter.streamChat()', async () => {
274
277
  const service = new AIService({ logger: silentLogger });
275
- const events: AIStreamEvent[] = [];
278
+ const events: TextStreamPart<ToolSet>[] = [];
276
279
  for await (const event of service.streamChat([{ role: 'user', content: 'Hi' }])) {
277
280
  events.push(event);
278
281
  }
@@ -289,14 +292,14 @@ describe('AIService', () => {
289
292
  };
290
293
  const service = new AIService({ adapter, logger: silentLogger });
291
294
 
292
- const events: AIStreamEvent[] = [];
295
+ const events: TextStreamPart<ToolSet>[] = [];
293
296
  for await (const event of service.streamChat([{ role: 'user', content: 'Hi' }])) {
294
297
  events.push(event);
295
298
  }
296
299
 
297
300
  expect(events).toHaveLength(2);
298
301
  expect(events[0].type).toBe('text-delta');
299
- expect(events[0].textDelta).toBe('response');
302
+ expect(events[0].type === 'text-delta' && events[0].text).toBe('response');
300
303
  expect(events[1].type).toBe('finish');
301
304
  });
302
305
 
@@ -379,7 +382,19 @@ describe('AI Routes', () => {
379
382
  expect(paths).toContain('DELETE /api/v1/ai/conversations/:id');
380
383
  });
381
384
 
382
- it('POST /api/v1/ai/chat should return chat result', async () => {
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 () => {
383
398
  const routes = buildAIRoutes(service, service.conversationService, silentLogger);
384
399
  const chatRoute = routes.find(r => r.path === '/api/v1/ai/chat')!;
385
400
 
@@ -387,10 +402,86 @@ describe('AI Routes', () => {
387
402
  body: { messages: [{ role: 'user', content: 'Hi' }] },
388
403
  });
389
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
+
390
447
  expect(response.status).toBe(200);
391
448
  expect((response.body as any).content).toBe('[memory] Hi');
392
449
  });
393
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
+
394
485
  it('POST /api/v1/ai/chat should return 400 without messages', async () => {
395
486
  const routes = buildAIRoutes(service, service.conversationService, silentLogger);
396
487
  const chatRoute = routes.find(r => r.path === '/api/v1/ai/chat')!;
@@ -528,16 +619,30 @@ describe('AI Routes', () => {
528
619
  expect((response.body as any).error).toContain('message.role');
529
620
  });
530
621
 
531
- it('POST /api/v1/ai/chat should return 400 for messages with non-string content', async () => {
622
+ it('POST /api/v1/ai/chat should return 400 for messages with non-string/non-array content', async () => {
532
623
  const routes = buildAIRoutes(service, service.conversationService, silentLogger);
533
624
  const chatRoute = routes.find(r => r.path === '/api/v1/ai/chat')!;
534
625
 
626
+ // Numeric content should be rejected
535
627
  const response = await chatRoute.handler({
536
628
  body: { messages: [{ role: 'user', content: 123 }] },
537
629
  });
538
-
539
630
  expect(response.status).toBe(400);
540
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');
541
646
  });
542
647
 
543
648
  it('POST /api/v1/ai/conversations/:id/messages should return 400 for invalid role', async () => {
@@ -617,6 +722,7 @@ describe('AI Routes', () => {
617
722
  { role: 'assistant', content: '' },
618
723
  { role: 'tool', content: '{"temp": 22}', toolCallId: 'call_1' },
619
724
  ],
725
+ stream: false,
620
726
  },
621
727
  });
622
728
 
@@ -633,6 +739,9 @@ describe('AIServicePlugin', () => {
633
739
  const services = new Map<string, unknown>();
634
740
  const hooks = new Map<string, Function[]>();
635
741
 
742
+ // Pre-register manifest service
743
+ services.set('manifest', { register: vi.fn() });
744
+
636
745
  return {
637
746
  registerService: vi.fn((name: string, service: unknown) => services.set(name, service)),
638
747
  replaceService: vi.fn((name: string, service: unknown) => services.set(name, service)),
@@ -728,4 +837,116 @@ describe('AIServicePlugin', () => {
728
837
 
729
838
  expect(ctx.hook).toHaveBeenCalledWith('ai:beforeChat', expect.any(Function));
730
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
+ const plugin = new AIServicePlugin();
872
+ const ctx = createMockContext();
873
+
874
+ const oldEnv = { ...process.env };
875
+ // Set env var, but the SDK won't be available in test environment
876
+ process.env.OPENAI_API_KEY = 'fake-openai-key';
877
+ delete process.env.AI_GATEWAY_MODEL;
878
+ delete process.env.ANTHROPIC_API_KEY;
879
+ delete process.env.GOOGLE_GENERATIVE_AI_API_KEY;
880
+
881
+ try {
882
+ await plugin.init(ctx);
883
+
884
+ const service = ctx.getService<AIService>('ai');
885
+ // Should fall back to memory because @ai-sdk/openai is not installed
886
+ expect(service.adapterName).toBe('memory');
887
+
888
+ // Verify warning was logged about SDK load failure
889
+ expect(silentLogger.warn).toHaveBeenCalledWith(
890
+ expect.stringContaining('Failed to load @ai-sdk/openai'),
891
+ expect.objectContaining({ error: expect.any(String) })
892
+ );
893
+
894
+ // Verify warning was logged about final fallback
895
+ expect(silentLogger.warn).toHaveBeenCalledWith(
896
+ expect.stringContaining('No LLM provider configured')
897
+ );
898
+ } finally {
899
+ process.env = oldEnv;
900
+ }
901
+ });
902
+
903
+ it('should prefer explicit adapter over auto-detection', async () => {
904
+ const customAdapter: LLMAdapter = {
905
+ name: 'custom-explicit',
906
+ chat: async () => ({ content: 'explicit' }),
907
+ complete: async () => ({ content: '' }),
908
+ };
909
+
910
+ const plugin = new AIServicePlugin({ adapter: customAdapter });
911
+ const ctx = createMockContext();
912
+
913
+ const oldEnv = { ...process.env };
914
+ process.env.OPENAI_API_KEY = 'fake-openai-key';
915
+
916
+ try {
917
+ await plugin.init(ctx);
918
+
919
+ const service = ctx.getService<AIService>('ai');
920
+ expect(service.adapterName).toBe('custom-explicit');
921
+
922
+ // Verify it logged as explicitly configured
923
+ expect(silentLogger.info).toHaveBeenCalledWith(
924
+ expect.stringContaining('explicitly configured')
925
+ );
926
+ } finally {
927
+ process.env = oldEnv;
928
+ }
929
+ });
930
+
931
+ it('should log adapter selection', async () => {
932
+ const plugin = new AIServicePlugin();
933
+ const ctx = createMockContext();
934
+
935
+ const oldEnv = { ...process.env };
936
+ delete process.env.AI_GATEWAY_MODEL;
937
+ delete process.env.OPENAI_API_KEY;
938
+ delete process.env.ANTHROPIC_API_KEY;
939
+ delete process.env.GOOGLE_GENERATIVE_AI_API_KEY;
940
+
941
+ try {
942
+ await plugin.init(ctx);
943
+
944
+ // Verify adapter selection was logged
945
+ expect(silentLogger.info).toHaveBeenCalledWith(
946
+ expect.stringContaining('Using LLM adapter')
947
+ );
948
+ } finally {
949
+ process.env = oldEnv;
950
+ }
951
+ });
731
952
  });