feat: support course-specific Qwen endpoints
This commit is contained in:
@@ -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.
|
- 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.
|
- 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.
|
- 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
|
## Creating a module
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
ALTER TABLE "course_ai_settings"
|
||||||
|
ADD COLUMN "endpoint_url" TEXT;
|
||||||
@@ -49,6 +49,7 @@ model CourseAiSetting {
|
|||||||
enabled Boolean @default(false)
|
enabled Boolean @default(false)
|
||||||
provider String @default("OPENAI")
|
provider String @default("OPENAI")
|
||||||
model String
|
model String
|
||||||
|
endpointUrl String? @map("endpoint_url")
|
||||||
encryptedApiKey String? @map("encrypted_api_key")
|
encryptedApiKey String? @map("encrypted_api_key")
|
||||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
|
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
|
||||||
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(3)
|
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(3)
|
||||||
|
|||||||
@@ -66,6 +66,7 @@ export class AgentService {
|
|||||||
provider: setting.provider as AiProvider,
|
provider: setting.provider as AiProvider,
|
||||||
model: setting.model,
|
model: setting.model,
|
||||||
apiKey: this.credentialService.decrypt(setting.encryptedApiKey),
|
apiKey: this.credentialService.decrypt(setting.encryptedApiKey),
|
||||||
|
endpointUrl: setting.endpointUrl,
|
||||||
json: true,
|
json: true,
|
||||||
system: this.systemPrompt(),
|
system: this.systemPrompt(),
|
||||||
messages: [{
|
messages: [{
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ describe('AiProviderService', () => {
|
|||||||
jest.restoreAllMocks();
|
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({
|
const fetchMock = jest.fn().mockResolvedValue({
|
||||||
ok: true,
|
ok: true,
|
||||||
status: 200,
|
status: 200,
|
||||||
@@ -20,20 +20,21 @@ describe('AiProviderService', () => {
|
|||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
global.fetch = fetchMock as typeof fetch;
|
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 service = new AiProviderService(config);
|
||||||
|
|
||||||
const result = await service.complete({
|
const result = await service.complete({
|
||||||
provider: 'QWEN',
|
provider: 'QWEN',
|
||||||
model: 'qwen3.7-plus',
|
model: 'qwen3.7-plus',
|
||||||
apiKey: 'qwen-secret',
|
apiKey: 'qwen-secret',
|
||||||
|
endpointUrl: 'https://course-endpoint.test/compatible-mode/v1',
|
||||||
system: 'system prompt',
|
system: 'system prompt',
|
||||||
messages: [{ role: 'user', content: 'hello' }],
|
messages: [{ role: 'user', content: 'hello' }],
|
||||||
json: true,
|
json: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(result.content).toBe('{"ok":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',
|
method: 'POST',
|
||||||
headers: expect.objectContaining({ Authorization: 'Bearer qwen-secret' }),
|
headers: expect.objectContaining({ Authorization: 'Bearer qwen-secret' }),
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ type CompletionInput = {
|
|||||||
provider: AiProvider;
|
provider: AiProvider;
|
||||||
model: string;
|
model: string;
|
||||||
apiKey: string;
|
apiKey: string;
|
||||||
|
endpointUrl?: string | null;
|
||||||
system: string;
|
system: string;
|
||||||
messages: Array<{ role: 'user' | 'assistant'; content: string }>;
|
messages: Array<{ role: 'user' | 'assistant'; content: string }>;
|
||||||
json?: boolean;
|
json?: boolean;
|
||||||
@@ -105,7 +106,7 @@ export class AiProviderService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private async qwen(input: CompletionInput): Promise<AiCompletionResult> {
|
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 normalizedUrl = configuredUrl?.replace(/\/+$/, '');
|
||||||
const endpoint = normalizedUrl
|
const endpoint = normalizedUrl
|
||||||
? `${normalizedUrl}${normalizedUrl.endsWith('/chat/completions') ? '' : '/chat/completions'}`
|
? `${normalizedUrl}${normalizedUrl.endsWith('/chat/completions') ? '' : '/chat/completions'}`
|
||||||
|
|||||||
@@ -14,6 +14,11 @@ export class UpdateCourseAiSettingDto {
|
|||||||
@MaxLength(120)
|
@MaxLength(120)
|
||||||
model: string;
|
model: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(500)
|
||||||
|
endpointUrl?: string;
|
||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
@MaxLength(500)
|
@MaxLength(500)
|
||||||
|
|||||||
@@ -118,6 +118,7 @@ export class InstructorService {
|
|||||||
enabled: setting?.enabled ?? false,
|
enabled: setting?.enabled ?? false,
|
||||||
provider: setting?.provider ?? 'OPENAI',
|
provider: setting?.provider ?? 'OPENAI',
|
||||||
model: setting?.model ?? 'gpt-4.1-mini',
|
model: setting?.model ?? 'gpt-4.1-mini',
|
||||||
|
endpointUrl: setting?.provider === 'QWEN' ? setting.endpointUrl : null,
|
||||||
hasApiKey: Boolean(setting?.encryptedApiKey),
|
hasApiKey: Boolean(setting?.encryptedApiKey),
|
||||||
updatedAt: setting?.updatedAt ?? null,
|
updatedAt: setting?.updatedAt ?? null,
|
||||||
};
|
};
|
||||||
@@ -138,6 +139,9 @@ export class InstructorService {
|
|||||||
if (input.enabled && !apiKey && (!existing?.encryptedApiKey || existing.provider !== input.provider)) {
|
if (input.enabled && !apiKey && (!existing?.encryptedApiKey || existing.provider !== input.provider)) {
|
||||||
throw new BadRequestException('AI 에이전트를 사용하거나 공급자를 변경하려면 해당 공급자의 API 키를 입력하세요.');
|
throw new BadRequestException('AI 에이전트를 사용하거나 공급자를 변경하려면 해당 공급자의 API 키를 입력하세요.');
|
||||||
}
|
}
|
||||||
|
const endpointUrl = input.provider === 'QWEN'
|
||||||
|
? this.normalizeQwenEndpoint(input.endpointUrl)
|
||||||
|
: null;
|
||||||
const setting = await this.prisma.courseAiSetting.upsert({
|
const setting = await this.prisma.courseAiSetting.upsert({
|
||||||
where: { courseId: course.id },
|
where: { courseId: course.id },
|
||||||
create: {
|
create: {
|
||||||
@@ -145,12 +149,14 @@ export class InstructorService {
|
|||||||
enabled: input.enabled,
|
enabled: input.enabled,
|
||||||
provider: input.provider,
|
provider: input.provider,
|
||||||
model: input.model.trim(),
|
model: input.model.trim(),
|
||||||
|
endpointUrl,
|
||||||
encryptedApiKey: apiKey ? this.aiCredentialService.encrypt(apiKey) : null,
|
encryptedApiKey: apiKey ? this.aiCredentialService.encrypt(apiKey) : null,
|
||||||
},
|
},
|
||||||
update: {
|
update: {
|
||||||
enabled: input.enabled,
|
enabled: input.enabled,
|
||||||
provider: input.provider,
|
provider: input.provider,
|
||||||
model: input.model.trim(),
|
model: input.model.trim(),
|
||||||
|
...(input.provider !== 'QWEN' || input.endpointUrl !== undefined ? { endpointUrl } : {}),
|
||||||
...(apiKey ? { encryptedApiKey: this.aiCredentialService.encrypt(apiKey) } : {}),
|
...(apiKey ? { encryptedApiKey: this.aiCredentialService.encrypt(apiKey) } : {}),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -159,11 +165,27 @@ export class InstructorService {
|
|||||||
enabled: setting.enabled,
|
enabled: setting.enabled,
|
||||||
provider: setting.provider,
|
provider: setting.provider,
|
||||||
model: setting.model,
|
model: setting.model,
|
||||||
|
endpointUrl: setting.provider === 'QWEN' ? setting.endpointUrl : null,
|
||||||
hasApiKey: Boolean(setting.encryptedApiKey),
|
hasApiKey: Boolean(setting.encryptedApiKey),
|
||||||
updatedAt: setting.updatedAt,
|
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) {
|
async listCourses(accessToken: string | undefined) {
|
||||||
const { authUser, instructor } = await this.getInstructor(accessToken);
|
const { authUser, instructor } = await this.getInstructor(accessToken);
|
||||||
const organizationName = authUser.primaryAffiliation?.organizationName?.trim();
|
const organizationName = authUser.primaryAffiliation?.organizationName?.trim();
|
||||||
|
|||||||
Reference in New Issue
Block a user