feat: support course-specific Qwen endpoints

This commit is contained in:
2026-10-14 15:12:08 +09:00
parent e3f0073124
commit f43ecd0f2a
8 changed files with 38 additions and 5 deletions
+1 -1
View File
@@ -24,7 +24,7 @@ This document defines the minimum product context an AI coding agent must follow
- Supported providers are OpenAI, Claude, Gemini, and Qwen.
- Provider API keys are encrypted by the LMS backend and are never returned to the desktop app.
- Set `AI_CREDENTIAL_ENCRYPTION_KEY` to a stable secret with at least 24 characters before enabling the feature.
- Qwen uses the international Model Studio OpenAI-compatible endpoint by default. Set `QWEN_API_BASE_URL` when the account uses a workspace-specific regional endpoint.
- Qwen uses the international Model Studio OpenAI-compatible endpoint by default. `QWEN_API_BASE_URL` sets the server default, and an instructor can override it for an individual course.
## Creating a module
@@ -0,0 +1,2 @@
ALTER TABLE "course_ai_settings"
ADD COLUMN "endpoint_url" TEXT;
+1
View File
@@ -49,6 +49,7 @@ model CourseAiSetting {
enabled Boolean @default(false)
provider String @default("OPENAI")
model String
endpointUrl String? @map("endpoint_url")
encryptedApiKey String? @map("encrypted_api_key")
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(3)
+1
View File
@@ -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: [{
+4 -3
View File
@@ -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' }),
}));
+2 -1
View File
@@ -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)
+22
View File
@@ -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();