@objectstack/service-ai 4.0.1 → 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.
- package/.turbo/turbo-build.log +11 -11
- package/CHANGELOG.md +9 -0
- package/dist/index.cjs +1120 -66
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +316 -78
- package/dist/index.d.ts +316 -78
- package/dist/index.js +1105 -63
- package/dist/index.js.map +1 -1
- package/package.json +26 -4
- package/src/__tests__/ai-service.test.ts +248 -27
- package/src/__tests__/auth-and-toolcalling.test.ts +30 -28
- package/src/__tests__/chatbot-features.test.ts +229 -82
- package/src/__tests__/metadata-tools.test.ts +964 -0
- package/src/__tests__/objectql-conversation-service.test.ts +34 -16
- package/src/__tests__/vercel-stream-encoder.test.ts +263 -0
- package/src/adapters/index.ts +2 -0
- package/src/adapters/memory-adapter.ts +17 -9
- package/src/adapters/vercel-adapter.ts +148 -0
- package/src/agent-runtime.ts +27 -3
- package/src/agents/index.ts +1 -0
- package/src/agents/metadata-assistant-agent.ts +87 -0
- package/src/ai-service.ts +68 -36
- package/src/conversation/in-memory-conversation-service.ts +2 -2
- package/src/conversation/objectql-conversation-service.ts +67 -18
- package/src/index.ts +21 -2
- package/src/plugin.ts +166 -9
- package/src/routes/agent-routes.ts +26 -3
- package/src/routes/ai-routes.ts +156 -13
- package/src/stream/index.ts +3 -0
- package/src/stream/vercel-stream-encoder.ts +129 -0
- package/src/tools/add-field.tool.ts +70 -0
- package/src/tools/create-object.tool.ts +66 -0
- package/src/tools/delete-field.tool.ts +38 -0
- package/src/tools/describe-metadata-object.tool.ts +32 -0
- package/src/tools/index.ts +12 -1
- package/src/tools/list-metadata-objects.tool.ts +34 -0
- package/src/tools/metadata-tools.ts +430 -0
- package/src/tools/modify-field.tool.ts +44 -0
- 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.
|
|
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
|
-
"@
|
|
18
|
-
"
|
|
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.
|
|
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 {
|
|
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:
|
|
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:
|
|
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:
|
|
61
|
-
const events:
|
|
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
|
-
|
|
117
|
-
|
|
118
|
-
|
|
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.
|
|
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
|
-
|
|
129
|
-
|
|
130
|
-
|
|
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.
|
|
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
|
-
|
|
144
|
-
|
|
145
|
-
|
|
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.
|
|
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
|
-
{
|
|
159
|
-
{
|
|
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].
|
|
164
|
-
expect(results[1].
|
|
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:
|
|
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:
|
|
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].
|
|
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
|
|
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
|
});
|
|
@@ -2,11 +2,12 @@
|
|
|
2
2
|
|
|
3
3
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
4
4
|
import type {
|
|
5
|
-
|
|
5
|
+
ModelMessage,
|
|
6
6
|
AIResult,
|
|
7
7
|
AIRequestOptions,
|
|
8
|
-
|
|
9
|
-
|
|
8
|
+
TextStreamPart,
|
|
9
|
+
ToolSet,
|
|
10
|
+
ToolCallPart,
|
|
10
11
|
LLMAdapter,
|
|
11
12
|
} from '@objectstack/spec/contracts';
|
|
12
13
|
import { AIService } from '../ai-service.js';
|
|
@@ -313,7 +314,7 @@ describe('chatWithTools — Enhanced Error Handling', () => {
|
|
|
313
314
|
|
|
314
315
|
const onToolError = vi.fn().mockReturnValue('continue');
|
|
315
316
|
const adapter = createMockAdapter([
|
|
316
|
-
{ content: '', toolCalls: [{
|
|
317
|
+
{ content: '', toolCalls: [{ type: 'tool-call' as const, toolCallId: 'c1', toolName: 'bad_tool', input: {} }] },
|
|
317
318
|
{ content: 'Recovered' },
|
|
318
319
|
]);
|
|
319
320
|
|
|
@@ -325,7 +326,7 @@ describe('chatWithTools — Enhanced Error Handling', () => {
|
|
|
325
326
|
|
|
326
327
|
expect(onToolError).toHaveBeenCalledTimes(1);
|
|
327
328
|
expect(onToolError).toHaveBeenCalledWith(
|
|
328
|
-
expect.objectContaining({
|
|
329
|
+
expect.objectContaining({ toolName: 'bad_tool' }),
|
|
329
330
|
'boom',
|
|
330
331
|
);
|
|
331
332
|
expect(result.content).toBe('Recovered');
|
|
@@ -338,7 +339,7 @@ describe('chatWithTools — Enhanced Error Handling', () => {
|
|
|
338
339
|
);
|
|
339
340
|
|
|
340
341
|
const adapter = createMockAdapter([
|
|
341
|
-
{ content: '', toolCalls: [{
|
|
342
|
+
{ content: '', toolCalls: [{ type: 'tool-call' as const, toolCallId: 'c1', toolName: 'abort_tool', input: {} }] },
|
|
342
343
|
// This would be the forced-final call
|
|
343
344
|
{ content: 'Aborted cleanly' },
|
|
344
345
|
]);
|
|
@@ -381,7 +382,7 @@ describe('chatWithTools — Enhanced Error Handling', () => {
|
|
|
381
382
|
);
|
|
382
383
|
|
|
383
384
|
const adapter = createMockAdapter([
|
|
384
|
-
{ content: '', toolCalls: [{
|
|
385
|
+
{ content: '', toolCalls: [{ type: 'tool-call' as const, toolCallId: 'c1', toolName: 'fail_tool', input: {} }] },
|
|
385
386
|
{ content: 'Error was fed back to model' },
|
|
386
387
|
]);
|
|
387
388
|
|
|
@@ -400,7 +401,7 @@ describe('chatWithTools — Enhanced Error Handling', () => {
|
|
|
400
401
|
|
|
401
402
|
const infiniteToolCall: AIResult = {
|
|
402
403
|
content: '',
|
|
403
|
-
toolCalls: [{
|
|
404
|
+
toolCalls: [{ type: 'tool-call' as const, toolCallId: 'c', toolName: 'flaky_tool', input: {} }],
|
|
404
405
|
};
|
|
405
406
|
const adapter = createMockAdapter(
|
|
406
407
|
Array(2).fill(infiniteToolCall).concat([{ content: 'Forced' }]),
|
|
@@ -430,8 +431,8 @@ describe('chatWithTools — Enhanced Error Handling', () => {
|
|
|
430
431
|
{
|
|
431
432
|
content: '',
|
|
432
433
|
toolCalls: [
|
|
433
|
-
{
|
|
434
|
-
{
|
|
434
|
+
{ type: 'tool-call' as const, toolCallId: 'c1', toolName: 'get_weather', input: { city: 'NYC' } },
|
|
435
|
+
{ type: 'tool-call' as const, toolCallId: 'c2', toolName: 'bad_tool', input: {} },
|
|
435
436
|
],
|
|
436
437
|
},
|
|
437
438
|
{ content: 'Weather ok, tool failed' },
|
|
@@ -447,12 +448,12 @@ describe('chatWithTools — Enhanced Error Handling', () => {
|
|
|
447
448
|
// Only called for the failing tool
|
|
448
449
|
expect(onToolError).toHaveBeenCalledTimes(1);
|
|
449
450
|
expect(onToolError).toHaveBeenCalledWith(
|
|
450
|
-
expect.objectContaining({
|
|
451
|
+
expect.objectContaining({ toolName: 'bad_tool' }),
|
|
451
452
|
'fail',
|
|
452
453
|
);
|
|
453
454
|
|
|
454
455
|
// Both tool results fed back
|
|
455
|
-
const secondCallMessages = (adapter.chat as any).mock.calls[1][0] as
|
|
456
|
+
const secondCallMessages = (adapter.chat as any).mock.calls[1][0] as ModelMessage[];
|
|
456
457
|
const toolMessages = secondCallMessages.filter(m => m.role === 'tool');
|
|
457
458
|
expect(toolMessages).toHaveLength(2);
|
|
458
459
|
expect(result.content).toBe('Weather ok, tool failed');
|
|
@@ -478,7 +479,7 @@ describe('streamChatWithTools', () => {
|
|
|
478
479
|
};
|
|
479
480
|
|
|
480
481
|
const service = new AIService({ adapter, logger: silentLogger, toolRegistry: registry });
|
|
481
|
-
const events:
|
|
482
|
+
const events: TextStreamPart<ToolSet>[] = [];
|
|
482
483
|
for await (const event of service.streamChatWithTools([{ role: 'user', content: 'Hi' }])) {
|
|
483
484
|
events.push(event);
|
|
484
485
|
}
|
|
@@ -486,16 +487,17 @@ describe('streamChatWithTools', () => {
|
|
|
486
487
|
// Should emit the probed result as text-delta + finish (no double model call)
|
|
487
488
|
expect(events).toHaveLength(2);
|
|
488
489
|
expect(events[0].type).toBe('text-delta');
|
|
489
|
-
expect(events[0].
|
|
490
|
+
expect((events[0] as any).text).toBe('Hello!');
|
|
490
491
|
expect(events[1].type).toBe('finish');
|
|
491
492
|
expect(adapter.chat).toHaveBeenCalledTimes(1);
|
|
492
493
|
});
|
|
493
494
|
|
|
494
495
|
it('should emit tool-call events during tool resolution', async () => {
|
|
495
|
-
const toolCall:
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
496
|
+
const toolCall: ToolCallPart = {
|
|
497
|
+
type: 'tool-call',
|
|
498
|
+
toolCallId: 'call_1',
|
|
499
|
+
toolName: 'get_weather',
|
|
500
|
+
input: { city: 'Tokyo' },
|
|
499
501
|
};
|
|
500
502
|
|
|
501
503
|
let chatCallIndex = 0;
|
|
@@ -512,7 +514,7 @@ describe('streamChatWithTools', () => {
|
|
|
512
514
|
};
|
|
513
515
|
|
|
514
516
|
const service = new AIService({ adapter, logger: silentLogger, toolRegistry: registry });
|
|
515
|
-
const events:
|
|
517
|
+
const events: TextStreamPart<ToolSet>[] = [];
|
|
516
518
|
for await (const event of service.streamChatWithTools(
|
|
517
519
|
[{ role: 'user', content: 'Weather in Tokyo?' }],
|
|
518
520
|
)) {
|
|
@@ -522,7 +524,7 @@ describe('streamChatWithTools', () => {
|
|
|
522
524
|
// Should have tool-call event followed by text-delta + finish (no double call)
|
|
523
525
|
const toolCallEvents = events.filter(e => e.type === 'tool-call');
|
|
524
526
|
expect(toolCallEvents).toHaveLength(1);
|
|
525
|
-
expect(toolCallEvents[0].
|
|
527
|
+
expect((toolCallEvents[0] as any).toolName).toBe('get_weather');
|
|
526
528
|
|
|
527
529
|
const finishEvent = events.find(e => e.type === 'finish');
|
|
528
530
|
expect(finishEvent).toBeDefined();
|
|
@@ -539,7 +541,7 @@ describe('streamChatWithTools', () => {
|
|
|
539
541
|
|
|
540
542
|
const emptyRegistry = new ToolRegistry();
|
|
541
543
|
const service = new AIService({ adapter, logger: silentLogger, toolRegistry: emptyRegistry });
|
|
542
|
-
const events:
|
|
544
|
+
const events: TextStreamPart<ToolSet>[] = [];
|
|
543
545
|
for await (const event of service.streamChatWithTools(
|
|
544
546
|
[{ role: 'user', content: 'Hi' }],
|
|
545
547
|
)) {
|
|
@@ -548,14 +550,14 @@ describe('streamChatWithTools', () => {
|
|
|
548
550
|
|
|
549
551
|
expect(events).toHaveLength(2);
|
|
550
552
|
expect(events[0].type).toBe('text-delta');
|
|
551
|
-
expect(events[0].
|
|
553
|
+
expect((events[0] as any).text).toBe('Fallback response');
|
|
552
554
|
expect(events[1].type).toBe('finish');
|
|
553
555
|
});
|
|
554
556
|
|
|
555
557
|
it('should respect maxIterations in streaming tool loop', async () => {
|
|
556
558
|
const infiniteToolCall: AIResult = {
|
|
557
559
|
content: '',
|
|
558
|
-
toolCalls: [{
|
|
560
|
+
toolCalls: [{ type: 'tool-call' as const, toolCallId: 'c', toolName: 'get_weather', input: { city: 'X' } }],
|
|
559
561
|
};
|
|
560
562
|
|
|
561
563
|
let callIndex = 0;
|
|
@@ -568,13 +570,13 @@ describe('streamChatWithTools', () => {
|
|
|
568
570
|
}),
|
|
569
571
|
complete: vi.fn(async () => ({ content: '' })),
|
|
570
572
|
async *streamChat() {
|
|
571
|
-
yield { type: 'text-delta' as const,
|
|
572
|
-
yield { type: 'finish' as const,
|
|
573
|
+
yield { type: 'text-delta' as const, id: '1', text: 'Forced stop' } as TextStreamPart<ToolSet>;
|
|
574
|
+
yield { type: 'finish' as const, finishReason: 'stop' as const, totalUsage: { promptTokens: 0, completionTokens: 0, totalTokens: 0 }, rawFinishReason: 'stop' } as unknown as TextStreamPart<ToolSet>;
|
|
573
575
|
},
|
|
574
576
|
};
|
|
575
577
|
|
|
576
578
|
const service = new AIService({ adapter, logger: silentLogger, toolRegistry: registry });
|
|
577
|
-
const events:
|
|
579
|
+
const events: TextStreamPart<ToolSet>[] = [];
|
|
578
580
|
for await (const event of service.streamChatWithTools(
|
|
579
581
|
[{ role: 'user', content: 'Loop' }],
|
|
580
582
|
{ maxIterations: 2 },
|
|
@@ -601,7 +603,7 @@ describe('streamChatWithTools', () => {
|
|
|
601
603
|
if (chatCallIndex === 1) {
|
|
602
604
|
return {
|
|
603
605
|
content: '',
|
|
604
|
-
toolCalls: [{
|
|
606
|
+
toolCalls: [{ type: 'tool-call' as const, toolCallId: 'c1', toolName: 'critical_fail', input: {} }],
|
|
605
607
|
};
|
|
606
608
|
}
|
|
607
609
|
return { content: 'Aborted' };
|
|
@@ -610,7 +612,7 @@ describe('streamChatWithTools', () => {
|
|
|
610
612
|
};
|
|
611
613
|
|
|
612
614
|
const service = new AIService({ adapter, logger: silentLogger, toolRegistry: registry });
|
|
613
|
-
const events:
|
|
615
|
+
const events: TextStreamPart<ToolSet>[] = [];
|
|
614
616
|
for await (const event of service.streamChatWithTools(
|
|
615
617
|
[{ role: 'user', content: 'Critical' }],
|
|
616
618
|
{ onToolError: () => 'abort' },
|