diff --git a/docs/AI_AGENT_GUIDE.md b/docs/AI_AGENT_GUIDE.md index 25cc003..5191107 100644 --- a/docs/AI_AGENT_GUIDE.md +++ b/docs/AI_AGENT_GUIDE.md @@ -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 diff --git a/prisma/migrations/20261014170000_add_course_ai_endpoint/migration.sql b/prisma/migrations/20261014170000_add_course_ai_endpoint/migration.sql new file mode 100644 index 0000000..3d97b3c --- /dev/null +++ b/prisma/migrations/20261014170000_add_course_ai_endpoint/migration.sql @@ -0,0 +1,2 @@ +ALTER TABLE "course_ai_settings" +ADD COLUMN "endpoint_url" TEXT; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 513b321..5ccb8da 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -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) diff --git a/src/ai/agent.service.ts b/src/ai/agent.service.ts index 7264b46..a59e30f 100644 --- a/src/ai/agent.service.ts +++ b/src/ai/agent.service.ts @@ -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: [{ diff --git a/src/ai/ai-provider.service.spec.ts b/src/ai/ai-provider.service.spec.ts index 3cbb432..cf2d40e 100644 --- a/src/ai/ai-provider.service.spec.ts +++ b/src/ai/ai-provider.service.spec.ts @@ -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' }), })); diff --git a/src/ai/ai-provider.service.ts b/src/ai/ai-provider.service.ts index a68c75f..972e402 100644 --- a/src/ai/ai-provider.service.ts +++ b/src/ai/ai-provider.service.ts @@ -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 { - const configuredUrl = this.configService.get('QWEN_API_BASE_URL')?.trim(); + const configuredUrl = input.endpointUrl?.trim() || this.configService.get('QWEN_API_BASE_URL')?.trim(); const normalizedUrl = configuredUrl?.replace(/\/+$/, ''); const endpoint = normalizedUrl ? `${normalizedUrl}${normalizedUrl.endsWith('/chat/completions') ? '' : '/chat/completions'}` diff --git a/src/ai/dto/update-course-ai-setting.dto.ts b/src/ai/dto/update-course-ai-setting.dto.ts index 0a29787..66d151d 100644 --- a/src/ai/dto/update-course-ai-setting.dto.ts +++ b/src/ai/dto/update-course-ai-setting.dto.ts @@ -14,6 +14,11 @@ export class UpdateCourseAiSettingDto { @MaxLength(120) model: string; + @IsOptional() + @IsString() + @MaxLength(500) + endpointUrl?: string; + @IsOptional() @IsString() @MaxLength(500) diff --git a/src/instructor/instructor.service.ts b/src/instructor/instructor.service.ts index 1a5b5c8..a3d774b 100644 --- a/src/instructor/instructor.service.ts +++ b/src/instructor/instructor.service.ts @@ -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();