feat: support course-specific Qwen endpoints
This commit is contained in:
@@ -66,6 +66,7 @@ export class AgentService {
|
||||
provider: setting.provider as AiProvider,
|
||||
model: setting.model,
|
||||
apiKey: this.credentialService.decrypt(setting.encryptedApiKey),
|
||||
endpointUrl: setting.endpointUrl,
|
||||
json: true,
|
||||
system: this.systemPrompt(),
|
||||
messages: [{
|
||||
|
||||
@@ -10,7 +10,7 @@ describe('AiProviderService', () => {
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('calls the Qwen OpenAI-compatible chat endpoint', async () => {
|
||||
it('prefers the course Qwen endpoint over the server default', async () => {
|
||||
const fetchMock = jest.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
status: 200,
|
||||
@@ -20,20 +20,21 @@ describe('AiProviderService', () => {
|
||||
}),
|
||||
});
|
||||
global.fetch = fetchMock as typeof fetch;
|
||||
const config = { get: jest.fn().mockReturnValue('https://example.test/compatible-mode/v1') } as unknown as ConfigService;
|
||||
const config = { get: jest.fn().mockReturnValue('https://server-default.test/compatible-mode/v1') } as unknown as ConfigService;
|
||||
const service = new AiProviderService(config);
|
||||
|
||||
const result = await service.complete({
|
||||
provider: 'QWEN',
|
||||
model: 'qwen3.7-plus',
|
||||
apiKey: 'qwen-secret',
|
||||
endpointUrl: 'https://course-endpoint.test/compatible-mode/v1',
|
||||
system: 'system prompt',
|
||||
messages: [{ role: 'user', content: 'hello' }],
|
||||
json: true,
|
||||
});
|
||||
|
||||
expect(result.content).toBe('{"ok":true}');
|
||||
expect(fetchMock).toHaveBeenCalledWith('https://example.test/compatible-mode/v1/chat/completions', expect.objectContaining({
|
||||
expect(fetchMock).toHaveBeenCalledWith('https://course-endpoint.test/compatible-mode/v1/chat/completions', expect.objectContaining({
|
||||
method: 'POST',
|
||||
headers: expect.objectContaining({ Authorization: 'Bearer qwen-secret' }),
|
||||
}));
|
||||
|
||||
@@ -7,6 +7,7 @@ type CompletionInput = {
|
||||
provider: AiProvider;
|
||||
model: string;
|
||||
apiKey: string;
|
||||
endpointUrl?: string | null;
|
||||
system: string;
|
||||
messages: Array<{ role: 'user' | 'assistant'; content: string }>;
|
||||
json?: boolean;
|
||||
@@ -105,7 +106,7 @@ export class AiProviderService {
|
||||
}
|
||||
|
||||
private async qwen(input: CompletionInput): Promise<AiCompletionResult> {
|
||||
const configuredUrl = this.configService.get<string>('QWEN_API_BASE_URL')?.trim();
|
||||
const configuredUrl = input.endpointUrl?.trim() || this.configService.get<string>('QWEN_API_BASE_URL')?.trim();
|
||||
const normalizedUrl = configuredUrl?.replace(/\/+$/, '');
|
||||
const endpoint = normalizedUrl
|
||||
? `${normalizedUrl}${normalizedUrl.endsWith('/chat/completions') ? '' : '/chat/completions'}`
|
||||
|
||||
@@ -14,6 +14,11 @@ export class UpdateCourseAiSettingDto {
|
||||
@MaxLength(120)
|
||||
model: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(500)
|
||||
endpointUrl?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(500)
|
||||
|
||||
@@ -118,6 +118,7 @@ export class InstructorService {
|
||||
enabled: setting?.enabled ?? false,
|
||||
provider: setting?.provider ?? 'OPENAI',
|
||||
model: setting?.model ?? 'gpt-4.1-mini',
|
||||
endpointUrl: setting?.provider === 'QWEN' ? setting.endpointUrl : null,
|
||||
hasApiKey: Boolean(setting?.encryptedApiKey),
|
||||
updatedAt: setting?.updatedAt ?? null,
|
||||
};
|
||||
@@ -138,6 +139,9 @@ export class InstructorService {
|
||||
if (input.enabled && !apiKey && (!existing?.encryptedApiKey || existing.provider !== input.provider)) {
|
||||
throw new BadRequestException('AI 에이전트를 사용하거나 공급자를 변경하려면 해당 공급자의 API 키를 입력하세요.');
|
||||
}
|
||||
const endpointUrl = input.provider === 'QWEN'
|
||||
? this.normalizeQwenEndpoint(input.endpointUrl)
|
||||
: null;
|
||||
const setting = await this.prisma.courseAiSetting.upsert({
|
||||
where: { courseId: course.id },
|
||||
create: {
|
||||
@@ -145,12 +149,14 @@ export class InstructorService {
|
||||
enabled: input.enabled,
|
||||
provider: input.provider,
|
||||
model: input.model.trim(),
|
||||
endpointUrl,
|
||||
encryptedApiKey: apiKey ? this.aiCredentialService.encrypt(apiKey) : null,
|
||||
},
|
||||
update: {
|
||||
enabled: input.enabled,
|
||||
provider: input.provider,
|
||||
model: input.model.trim(),
|
||||
...(input.provider !== 'QWEN' || input.endpointUrl !== undefined ? { endpointUrl } : {}),
|
||||
...(apiKey ? { encryptedApiKey: this.aiCredentialService.encrypt(apiKey) } : {}),
|
||||
},
|
||||
});
|
||||
@@ -159,11 +165,27 @@ export class InstructorService {
|
||||
enabled: setting.enabled,
|
||||
provider: setting.provider,
|
||||
model: setting.model,
|
||||
endpointUrl: setting.provider === 'QWEN' ? setting.endpointUrl : null,
|
||||
hasApiKey: Boolean(setting.encryptedApiKey),
|
||||
updatedAt: setting.updatedAt,
|
||||
};
|
||||
}
|
||||
|
||||
private normalizeQwenEndpoint(value: string | undefined): string | null {
|
||||
const endpoint = value?.trim();
|
||||
if (!endpoint) return null;
|
||||
let url: URL;
|
||||
try {
|
||||
url = new URL(endpoint);
|
||||
} catch {
|
||||
throw new BadRequestException('Qwen 엔드포인트 주소가 올바르지 않습니다.');
|
||||
}
|
||||
if (url.protocol !== 'https:' || url.username || url.password) {
|
||||
throw new BadRequestException('Qwen 엔드포인트는 인증정보가 없는 HTTPS 주소여야 합니다.');
|
||||
}
|
||||
return endpoint.replace(/\/+$/, '');
|
||||
}
|
||||
|
||||
async listCourses(accessToken: string | undefined) {
|
||||
const { authUser, instructor } = await this.getInstructor(accessToken);
|
||||
const organizationName = authUser.primaryAffiliation?.organizationName?.trim();
|
||||
|
||||
Reference in New Issue
Block a user