feat: add managed A-Copilot provider using shared AI chat backend
This commit is contained in:
@@ -0,0 +1,107 @@
|
||||
import type { ConfigService } from '@nestjs/config';
|
||||
import { ChatService } from '../chat/chat.service';
|
||||
import { AgentService } from './agent.service';
|
||||
import { AiProviderService } from './ai-provider.service';
|
||||
import { getCopilotConfiguration } from './copilot-backend';
|
||||
|
||||
const config = (values: Record<string, string>) => ({ get: (key: string, fallback?: string) => values[key] ?? fallback }) as ConfigService;
|
||||
const configuration = {
|
||||
AI_PROVIDER: 'futuredrive', FUTUREDRIVE_LLM_BASE_URL: 'https://shared-ai.test/',
|
||||
FUTUREDRIVE_LLM_API_KEY: 'server-only-secret', FUTUREDRIVE_LLM_LANG: 'ko',
|
||||
};
|
||||
const plan = { summary: '분석 완료', explanation: '확인한 파일을 기준으로 답변합니다.', changes: [], commands: [], createModule: null };
|
||||
|
||||
describe('managed A-Copilot backend', () => {
|
||||
const originalFetch = global.fetch;
|
||||
afterEach(() => { global.fetch = originalFetch; jest.restoreAllMocks(); });
|
||||
|
||||
it('uses exactly the same FutureDrive transport and server key as the AI tab', async () => {
|
||||
const fetchMock = jest.fn().mockResolvedValue({ ok: true, json: async () => ({ answer: JSON.stringify(plan) }) });
|
||||
global.fetch = fetchMock as typeof fetch;
|
||||
const settings = config(configuration);
|
||||
const chat = Object.assign(Object.create(ChatService.prototype), { configService: settings });
|
||||
await chat.requestAiChatCompletion('ignored-model', [{ role: 'user', content: '학습 질문' }], '공식 교육 내용');
|
||||
const provider = new AiProviderService(settings);
|
||||
await provider.complete({
|
||||
provider: 'A_COPILOT', model: 'untrusted-model', apiKey: 'untrusted-key', endpointUrl: 'https://untrusted.test',
|
||||
system: 'Return an agent JSON plan', json: true, messages: [{ role: 'user', content: '워크스페이스 문맥' }],
|
||||
});
|
||||
for (const [url, request] of fetchMock.mock.calls) {
|
||||
expect(url).toBe('https://shared-ai.test/api/chat/complete');
|
||||
expect(request.headers).toEqual({ 'X-API-Key': 'server-only-secret', 'Content-Type': 'application/json', 'ngrok-skip-browser-warning': '1' });
|
||||
const body = JSON.parse(request.body);
|
||||
expect(Object.keys(body).sort()).toEqual(['lang', 'messages']);
|
||||
expect(body.lang).toBe('ko');
|
||||
expect(request.body).not.toMatch(/server-only-secret|untrusted/);
|
||||
}
|
||||
expect(JSON.parse(fetchMock.mock.calls[0][1].body).messages[0].content).toBe('공식 교육 내용');
|
||||
expect(JSON.parse(fetchMock.mock.calls[1][1].body).messages).toEqual([
|
||||
{ role: 'user', content: 'Return an agent JSON plan' }, { role: 'user', content: '워크스페이스 문맥' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('follows the AI tab server selection when OpenAI is configured, without accepting course overrides', async () => {
|
||||
const fetchMock = jest.fn().mockResolvedValue({ ok: true, json: async () => ({ choices: [{ message: { content: JSON.stringify(plan) } }] }) });
|
||||
global.fetch = fetchMock as typeof fetch;
|
||||
const provider = new AiProviderService(config({ AI_PROVIDER: 'openai', OPENAI_API_KEY: 'shared-openai-key', OPENAI_CHAT_MODEL: 'gpt-6-astra' }));
|
||||
await provider.complete({ provider: 'A_COPILOT', model: 'ignored', apiKey: 'ignored', endpointUrl: 'https://untrusted.test', system: 'agent instructions', json: true, messages: [{ role: 'user', content: 'test' }] });
|
||||
expect(fetchMock.mock.calls[0][0]).toBe('https://api.openai.com/v1/chat/completions');
|
||||
expect(fetchMock.mock.calls[0][1].headers.Authorization).toBe('Bearer shared-openai-key');
|
||||
expect(JSON.parse(fetchMock.mock.calls[0][1].body)).toMatchObject({ model: 'gpt-6-astra', response_format: { type: 'json_object' }, messages: [{ role: 'system', content: 'agent instructions' }, { role: 'user', content: 'test' }] });
|
||||
});
|
||||
|
||||
it('never falls back to a different server provider when its configured key is missing', async () => {
|
||||
const settings = config({ AI_PROVIDER: 'futuredrive', OPENAI_API_KEY: 'other-provider-key' });
|
||||
expect(getCopilotConfiguration(settings)).toMatchObject({ backend: 'futuredrive', configured: false });
|
||||
const fetchMock = jest.fn(); global.fetch = fetchMock as typeof fetch;
|
||||
const provider = new AiProviderService(settings);
|
||||
await expect(provider.complete({ provider: 'A_COPILOT', model: 'a-copilot', apiKey: 'course-key', system: 'test', messages: [] })).rejects.toThrow(/서버 연결/);
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ ok: false, status: 503, json: async () => ({ message: 'upstream unavailable' }) },
|
||||
{ ok: true, status: 200, json: async () => ({ answer: '' }) },
|
||||
{ ok: true, status: 200, json: async () => { throw Error('invalid JSON'); } },
|
||||
])('rejects upstream failures or empty answers instead of inventing a successful plan', async response => {
|
||||
global.fetch = jest.fn().mockResolvedValue(response) as typeof fetch;
|
||||
const provider = new AiProviderService(config(configuration));
|
||||
await expect(provider.complete({ provider: 'A_COPILOT', model: 'a-copilot', apiKey: '', system: 'test', messages: [] })).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('does not silently send images to the text-only API', async () => {
|
||||
const fetchMock = jest.fn(); global.fetch = fetchMock as typeof fetch;
|
||||
const provider = new AiProviderService(config(configuration));
|
||||
await expect(provider.complete({ provider: 'A_COPILOT', model: 'a-copilot', apiKey: '', system: 'test', messages: [{ role: 'user', content: 'test', images: [{ mimeType: 'image/png', data: 'test' }] }] })).rejects.toThrow(/Qwen/);
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('creates and repairs an agent JSON plan using server credentials, without a course credential', async () => {
|
||||
const fetchMock = jest.fn()
|
||||
.mockResolvedValueOnce({ ok: true, json: async () => ({ answer: 'invalid plan JSON' }) })
|
||||
.mockResolvedValueOnce({ ok: true, json: async () => ({ answer: JSON.stringify(plan) }) });
|
||||
global.fetch = fetchMock as typeof fetch;
|
||||
const decrypt = jest.fn();
|
||||
const setting = { provider: 'A_COPILOT', model: 'a-copilot', enabled: true, encryptedApiKey: null, endpointUrl: null };
|
||||
const provider = new AiProviderService(config(configuration));
|
||||
const service = Object.assign(Object.create(AgentService.prototype), {
|
||||
getLmsUser: jest.fn().mockResolvedValue({ id: 'student' }),
|
||||
getStudentCourse: jest.fn().mockResolvedValue({ id: 'course', title: '실습' }),
|
||||
prisma: { courseAiSetting: { findUnique: jest.fn().mockResolvedValue(setting) } },
|
||||
credentialService: { decrypt }, providerService: provider,
|
||||
}) as AgentService;
|
||||
await expect(service.status('token', 'course')).resolves.toEqual({ enabled: true, provider: 'A_COPILOT', model: 'A-Copilot', imagesEnabled: false });
|
||||
const result = await service.createPlan('token', { courseId: 'course', request: '파일 분석', modules: [{ name: 'NAV2-Control', files: [{ path: 'config/control.yaml', content: 'velocity: 0.5' }] }], toolProtocol: 1 });
|
||||
expect(result).toMatchObject({ provider: 'A_COPILOT', model: 'A-Copilot', plan });
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
expect(decrypt).not.toHaveBeenCalled();
|
||||
const body = JSON.parse(fetchMock.mock.calls[0][1].body);
|
||||
expect(body.messages[0].content).toContain('JSON');
|
||||
expect(body.messages[1].content).toContain('velocity: 0.5');
|
||||
expect(JSON.stringify(result)).not.toContain('server-only-secret');
|
||||
jest.spyOn(provider, 'copilotConfigured').mockReturnValue(false);
|
||||
await expect(service.status('token', 'course')).resolves.toMatchObject({ enabled: false, imagesEnabled: false });
|
||||
await expect(service.createPlan('token', { courseId: 'course', request: 'test', modules: [] })).rejects.toThrow(/설정되어 있지/);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user