@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.
- package/.turbo/turbo-build.log +11 -11
- package/CHANGELOG.md +20 -0
- package/dist/index.cjs +1245 -54
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +344 -77
- package/dist/index.d.ts +344 -77
- package/dist/index.js +1230 -51
- 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 +627 -0
- 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 +174 -22
- package/src/conversation/in-memory-conversation-service.ts +2 -2
- package/src/conversation/objectql-conversation-service.ts +67 -18
- package/src/index.ts +22 -3
- package/src/plugin.ts +166 -9
- package/src/routes/agent-routes.ts +28 -3
- package/src/routes/ai-routes.ts +231 -14
- package/src/routes/index.ts +1 -1
- 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
|
});
|