diff --git a/CLAUDE.md b/CLAUDE.md index c045c1f7..cc382e99 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -117,11 +117,15 @@ public interface AiProvider { ``` 职责:定义供应商无关的 AI 能力。业务代码只依赖 Provider 接口或 Registry;七牛 RTI、 -Qwen、Doubao、DeepSeek、MiniMax、讯飞等供应商差异全部留在 `infrastructure`。 +七牛 MaaS、Qwen、Doubao、DeepSeek、MiniMax、讯飞等供应商差异全部留在 +`infrastructure`。 Realtime 默认路由为七牛 RTI `qwen3.5-omni-plus-realtime`,百炼 `qwen3.5-omni-flash-realtime` 仅作为可回退错误的后备。七牛控制面 Session 的创建、 短期媒体凭证使用和 Stop 均由 Realtime Provider/Component 承担;短期凭证不得返回客户端 或持久化。 +LLM 默认路由为七牛 MaaS `qwen/qwen3.5-plus`,可重试错误时回退到百炼 +`qwen3.5-plus`;七牛 MaaS DeepSeek 与 DeepSeek 官方直连 Provider 仅保留为显式回滚能力。 +七牛 MaaS API Key 不得返回客户端、持久化或写入日志。 ## 3. 当前实现矩阵 diff --git a/README.md b/README.md index 89476182..395444aa 100644 --- a/README.md +++ b/README.md @@ -138,8 +138,9 @@ Service 不依赖阿里云 SDK、JDBC、内存存储或 SMTP 的具体实现;I ### AI 与语音 - 七牛 RTI Realtime(默认) -- Qwen Realtime(后备)/ LLM / ASR / TTS -- DeepSeek LLM +- 七牛 MaaS LLM(Qwen 3.5 Plus 主模型,阿里百炼 Qwen 3.5 Plus 后备) +- Qwen Realtime(后备)/ ASR / TTS +- Qwen / DeepSeek 官方直连 LLM(显式回滚用) - Doubao ASR - MiniMax / CosyVoice TTS - 科大讯飞发音评分 @@ -148,6 +149,9 @@ Service 不依赖阿里云 SDK、JDBC、内存存储或 SMTP 的具体实现;I Realtime 默认使用七牛 RTI 的 `qwen3.5-omni-plus-realtime`、`default_assistant`、 `Tina` 和 `platform_rtc`;七牛出现可回退错误时切换到百炼 Flash。七牛长期 API Key 只保存在后端,创建 Session 返回的短期媒体 token 仅用于服务端 SDP 协商。 +LLM 默认通过七牛 MaaS 调用 `qwen/qwen3.5-plus`,可重试错误时回退到阿里百炼 +`qwen3.5-plus`;七牛 MaaS DeepSeek 与 DeepSeek 官方直连 LLM 仅作为显式回滚能力。 +`QINIU_MAAS_API_KEY` 只能配置在后端环境中。 ## 本地启动 diff --git a/backend/unispeaking-server/src/main/java/com/unispeaking/component/scene/CustomSceneGenerator.java b/backend/unispeaking-server/src/main/java/com/unispeaking/component/scene/CustomSceneGenerator.java index f4a6a9f8..be8e0dc5 100644 --- a/backend/unispeaking-server/src/main/java/com/unispeaking/component/scene/CustomSceneGenerator.java +++ b/backend/unispeaking-server/src/main/java/com/unispeaking/component/scene/CustomSceneGenerator.java @@ -80,7 +80,22 @@ public CustomSceneDefinition generate( String content = providerRegistry.executeLlmTask(attemptPrompt, null); long llmMillis = elapsedMillis(llmStartedAt); long parseStartedAt = System.nanoTime(); - CustomSceneDefinition definition = parse(sceneId, userId, content); + CustomSceneDefinition definition; + try { + definition = parse(sceneId, userId, content); + } + catch (BusinessException exception) { + if ("CUSTOM_SCENE_LLM_RESPONSE_INVALID".equals(exception.code())) { + LOGGER.warn( + "custom scene LLM response rejected sceneId={} attempt={} llmMs={} parseMs={} responseChars={}", + sceneId, + attempt, + llmMillis, + elapsedMillis(parseStartedAt), + content.length()); + } + throw exception; + } LOGGER.info( "custom scene LLM completed sceneId={} attempt={} llmMs={} parseMs={}", sceneId, @@ -93,10 +108,6 @@ public CustomSceneDefinition generate( if (!"CUSTOM_SCENE_LLM_RESPONSE_INVALID".equals(exception.code())) { throw exception; } - LOGGER.warn( - "custom scene LLM response rejected sceneId={} attempt={}", - sceneId, - attempt); lastFailure = exception; } } diff --git a/backend/unispeaking-server/src/main/java/com/unispeaking/infrastructure/ai/qiniu/QiniuMaasLlmClient.java b/backend/unispeaking-server/src/main/java/com/unispeaking/infrastructure/ai/qiniu/QiniuMaasLlmClient.java new file mode 100644 index 00000000..e54ae7bd --- /dev/null +++ b/backend/unispeaking-server/src/main/java/com/unispeaking/infrastructure/ai/qiniu/QiniuMaasLlmClient.java @@ -0,0 +1,201 @@ +package com.unispeaking.infrastructure.ai.qiniu; + +import com.unispeaking.common.exception.BusinessException; +import com.unispeaking.infrastructure.config.QiniuMaasProperties; +import java.io.IOException; +import java.io.InputStream; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.nio.charset.StandardCharsets; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import tools.jackson.core.JacksonException; +import tools.jackson.databind.JsonNode; +import tools.jackson.databind.ObjectMapper; + +public final class QiniuMaasLlmClient { + + private static final Logger LOGGER = LoggerFactory.getLogger(QiniuMaasLlmClient.class); + + private final HttpClient httpClient; + private final ObjectMapper objectMapper; + private final QiniuMaasProperties properties; + private final URI endpoint; + + public QiniuMaasLlmClient( + HttpClient httpClient, + ObjectMapper objectMapper, + QiniuMaasProperties properties) { + this.httpClient = Objects.requireNonNull(httpClient, "Qiniu MaaS HTTP client is required"); + this.objectMapper = Objects.requireNonNull(objectMapper, "Qiniu MaaS JSON mapper is required"); + this.properties = Objects.requireNonNull(properties, "Qiniu MaaS properties are required"); + properties.validate(); + this.endpoint = properties.chatCompletionsUri(); + } + + QiniuMaasLlmClient( + HttpClient httpClient, + ObjectMapper objectMapper, + QiniuMaasProperties properties, + URI endpoint) { + this.httpClient = Objects.requireNonNull(httpClient, "Qiniu MaaS HTTP client is required"); + this.objectMapper = Objects.requireNonNull(objectMapper, "Qiniu MaaS JSON mapper is required"); + this.properties = Objects.requireNonNull(properties, "Qiniu MaaS properties are required"); + properties.validate(); + this.endpoint = Objects.requireNonNull(endpoint, "Qiniu MaaS endpoint is required"); + } + + public String execute(String model, String promptValue) { + String prompt = trim(promptValue); + if (prompt.isBlank()) { + throw new ProviderFailure("INVALID_LLM_PROMPT", "LLM task prompt is required", false); + } + if (properties.apiKey().isBlank()) { + throw new ProviderFailure( + "QINIU_MAAS_CREDENTIAL_MISSING", + "Set QINIU_MAAS_API_KEY before calling Qiniu MaaS LLM", + true); + } + + long startedAt = System.nanoTime(); + LOGGER.info( + "Qiniu MaaS LLM request started model={} promptChars={} maxOutputTokens={} timeoutMs={}", + model, + prompt.length(), + properties.maxOutputTokens(), + properties.readTimeout().toMillis()); + try { + Map body = Map.of( + "model", model, + "messages", List.of(Map.of("role", "user", "content", prompt)), + "stream", false, + "max_tokens", properties.maxOutputTokens()); + HttpRequest request = HttpRequest.newBuilder() + .uri(endpoint) + .timeout(properties.readTimeout()) + .header("Authorization", "Bearer " + properties.apiKey()) + .header("Content-Type", "application/json") + .POST(HttpRequest.BodyPublishers.ofString( + objectMapper.writeValueAsString(body), + StandardCharsets.UTF_8)) + .build(); + HttpResponse response = httpClient.send( + request, + HttpResponse.BodyHandlers.ofInputStream()); + byte[] responseBody; + try (InputStream input = response.body()) { + responseBody = input.readNBytes(properties.maxResponseBytes() + 1); + } + if (responseBody.length > properties.maxResponseBytes()) { + throw failure( + "QINIU_MAAS_LLM_RESPONSE_TOO_LARGE", + "Qiniu MaaS LLM response exceeds the configured limit", + true, + model, + startedAt); + } + if (response.statusCode() < 200 || response.statusCode() >= 300) { + boolean retryable = response.statusCode() != 401 + && response.statusCode() != 403; + throw failure( + "QINIU_MAAS_LLM_REQUEST_FAILED", + "Qiniu MaaS LLM returned HTTP " + response.statusCode(), + retryable, + model, + startedAt); + } + JsonNode root = objectMapper.readTree( + new String(responseBody, StandardCharsets.UTF_8)); + String content = root.path("choices") + .path(0) + .path("message") + .path("content") + .asString(""); + if (content.isBlank()) { + throw failure( + "QINIU_MAAS_LLM_EMPTY_RESPONSE", + "Qiniu MaaS LLM returned no message content", + true, + model, + startedAt); + } + LOGGER.info( + "Qiniu MaaS LLM request completed model={} status={} durationMs={} responseChars={}", + model, + response.statusCode(), + elapsedMillis(startedAt), + content.length()); + return content; + } + catch (ProviderFailure exception) { + throw exception; + } + catch (JacksonException exception) { + throw failure( + "QINIU_MAAS_LLM_RESPONSE_INVALID", + "Qiniu MaaS LLM response is not valid JSON", + true, + model, + startedAt); + } + catch (IOException exception) { + throw failure( + "QINIU_MAAS_LLM_IO_ERROR", + "Failed to call Qiniu MaaS LLM", + true, + model, + startedAt); + } + catch (InterruptedException exception) { + Thread.currentThread().interrupt(); + throw failure( + "QINIU_MAAS_LLM_INTERRUPTED", + "Qiniu MaaS LLM call was interrupted", + false, + model, + startedAt); + } + } + + private ProviderFailure failure( + String code, + String message, + boolean retryable, + String model, + long startedAt) { + LOGGER.warn( + "Qiniu MaaS LLM request failed model={} durationMs={} errorCode={} retryable={}", + model, + elapsedMillis(startedAt), + code, + retryable); + return new ProviderFailure(code, message, retryable); + } + + private static long elapsedMillis(long startedAt) { + return (System.nanoTime() - startedAt) / 1_000_000; + } + + private static String trim(String value) { + return value == null ? "" : value.trim(); + } + + static final class ProviderFailure extends BusinessException { + + private final boolean retryable; + + ProviderFailure(String code, String message, boolean retryable) { + super(code, message); + this.retryable = retryable; + } + + boolean retryable() { + return retryable; + } + } +} diff --git a/backend/unispeaking-server/src/main/java/com/unispeaking/infrastructure/ai/qiniu/QiniuMaasLlmProvider.java b/backend/unispeaking-server/src/main/java/com/unispeaking/infrastructure/ai/qiniu/QiniuMaasLlmProvider.java new file mode 100644 index 00000000..16eaea6d --- /dev/null +++ b/backend/unispeaking-server/src/main/java/com/unispeaking/infrastructure/ai/qiniu/QiniuMaasLlmProvider.java @@ -0,0 +1,43 @@ +package com.unispeaking.infrastructure.ai.qiniu; + +import com.unispeaking.common.exception.BusinessException; +import com.unispeaking.provider.LlmProvider; +import java.util.Objects; +import java.util.Set; + +public final class QiniuMaasLlmProvider extends LlmProvider { + + public static final String PROVIDER_ID = "qiniu-maas"; + + private final QiniuMaasLlmClient client; + private final String model; + + public QiniuMaasLlmProvider(QiniuMaasLlmClient client, String model) { + super(PROVIDER_ID, Set.of(requiredModel(model))); + this.client = Objects.requireNonNull(client, "Qiniu MaaS LLM client is required"); + this.model = requiredModel(model); + } + + @Override + public String executeLlmTask(String prompt, String token) { + try { + return client.execute(model, prompt); + } + catch (QiniuMaasLlmClient.ProviderFailure exception) { + throw exception.retryable() + ? retryableFailure(exception.code(), exception.getMessage()) + : nonRetryableFailure(exception.code(), exception.getMessage()); + } + catch (BusinessException exception) { + throw exception; + } + } + + private static String requiredModel(String value) { + String model = value == null ? "" : value.trim(); + if (model.isBlank()) { + throw new IllegalArgumentException("Qiniu MaaS LLM model is required"); + } + return model; + } +} diff --git a/backend/unispeaking-server/src/main/java/com/unispeaking/infrastructure/config/QiniuMaasConfig.java b/backend/unispeaking-server/src/main/java/com/unispeaking/infrastructure/config/QiniuMaasConfig.java new file mode 100644 index 00000000..c254485a --- /dev/null +++ b/backend/unispeaking-server/src/main/java/com/unispeaking/infrastructure/config/QiniuMaasConfig.java @@ -0,0 +1,40 @@ +package com.unispeaking.infrastructure.config; + +import com.unispeaking.infrastructure.ai.qiniu.QiniuMaasLlmClient; +import com.unispeaking.infrastructure.ai.qiniu.QiniuMaasLlmProvider; +import com.unispeaking.provider.LlmProvider; +import java.net.http.HttpClient; +import org.springframework.core.annotation.Order; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import tools.jackson.databind.ObjectMapper; + +@Configuration +public class QiniuMaasConfig { + + @Bean + QiniuMaasLlmClient qiniuMaasLlmClient( + QiniuMaasProperties properties, + ObjectMapper objectMapper) { + HttpClient httpClient = HttpClient.newBuilder() + .connectTimeout(properties.connectTimeout()) + .build(); + return new QiniuMaasLlmClient(httpClient, objectMapper, properties); + } + + @Bean + @Order(0) + LlmProvider qiniuMaasPrimaryLlmProvider( + QiniuMaasLlmClient client, + QiniuMaasProperties properties) { + return new QiniuMaasLlmProvider(client, properties.primaryModel()); + } + + @Bean + @Order(1) + LlmProvider qiniuMaasFallbackLlmProvider( + QiniuMaasLlmClient client, + QiniuMaasProperties properties) { + return new QiniuMaasLlmProvider(client, properties.fallbackModel()); + } +} diff --git a/backend/unispeaking-server/src/main/java/com/unispeaking/infrastructure/config/QiniuMaasProperties.java b/backend/unispeaking-server/src/main/java/com/unispeaking/infrastructure/config/QiniuMaasProperties.java new file mode 100644 index 00000000..ccb2d220 --- /dev/null +++ b/backend/unispeaking-server/src/main/java/com/unispeaking/infrastructure/config/QiniuMaasProperties.java @@ -0,0 +1,87 @@ +package com.unispeaking.infrastructure.config; + +import jakarta.annotation.PostConstruct; +import java.net.URI; +import java.time.Duration; +import java.util.Locale; +import java.util.Set; +import org.springframework.boot.context.properties.ConfigurationProperties; + +@ConfigurationProperties(prefix = "ai.qiniu-maas") +public record QiniuMaasProperties( + String baseUrl, + String apiKey, + String primaryModel, + String fallbackModel, + Duration connectTimeout, + Duration readTimeout, + int maxResponseBytes, + int maxOutputTokens) { + + private static final String DEFAULT_BASE_URL = "https://api.qnaigc.com/v1"; + private static final String DEFAULT_PRIMARY_MODEL = "deepseek/deepseek-v4-flash"; + private static final String DEFAULT_FALLBACK_MODEL = "qwen/qwen3.5-plus"; + private static final Set TRUSTED_HOSTS = Set.of( + "api.qnaigc.com", + "openai.sufy.com"); + + public QiniuMaasProperties { + baseUrl = trimTrailingSlash(defaultIfBlank(baseUrl, DEFAULT_BASE_URL)); + apiKey = trim(apiKey); + primaryModel = defaultIfBlank(primaryModel, DEFAULT_PRIMARY_MODEL); + fallbackModel = defaultIfBlank(fallbackModel, DEFAULT_FALLBACK_MODEL); + connectTimeout = positiveOrDefault(connectTimeout, Duration.ofSeconds(10)); + readTimeout = positiveOrDefault(readTimeout, Duration.ofSeconds(90)); + maxResponseBytes = maxResponseBytes > 0 ? maxResponseBytes : 2 * 1024 * 1024; + maxOutputTokens = maxOutputTokens > 0 ? maxOutputTokens : 4096; + } + + @PostConstruct + public void validate() { + URI uri; + try { + uri = URI.create(baseUrl); + } + catch (IllegalArgumentException exception) { + throw new IllegalStateException("ai.qiniu-maas.base-url must be a valid URI", exception); + } + String host = uri.getHost() == null ? "" : uri.getHost().toLowerCase(Locale.ROOT); + if (!uri.isAbsolute() + || !"https".equalsIgnoreCase(uri.getScheme()) + || !TRUSTED_HOSTS.contains(host) + || uri.getUserInfo() != null + || uri.getPort() != -1 + || !"/v1".equals(uri.getPath()) + || uri.getRawQuery() != null + || uri.getRawFragment() != null) { + throw new IllegalStateException( + "ai.qiniu-maas.base-url must be a trusted Qiniu MaaS v1 endpoint"); + } + if (primaryModel.equalsIgnoreCase(fallbackModel)) { + throw new IllegalStateException( + "ai.qiniu-maas primary and fallback models must be different"); + } + } + + public URI chatCompletionsUri() { + return URI.create(baseUrl + "/chat/completions"); + } + + private static Duration positiveOrDefault(Duration value, Duration fallback) { + return value == null || value.isZero() || value.isNegative() ? fallback : value; + } + + private static String defaultIfBlank(String value, String fallback) { + String normalized = trim(value); + return normalized.isBlank() ? fallback : normalized; + } + + private static String trim(String value) { + return value == null ? "" : value.trim(); + } + + private static String trimTrailingSlash(String value) { + while (value.endsWith("/")) value = value.substring(0, value.length() - 1); + return value; + } +} diff --git a/backend/unispeaking-server/src/main/java/com/unispeaking/provider/AiProviderRegistry.java b/backend/unispeaking-server/src/main/java/com/unispeaking/provider/AiProviderRegistry.java index 4bbc3b31..799de65e 100644 --- a/backend/unispeaking-server/src/main/java/com/unispeaking/provider/AiProviderRegistry.java +++ b/backend/unispeaking-server/src/main/java/com/unispeaking/provider/AiProviderRegistry.java @@ -39,6 +39,8 @@ public record RoutedResult( public static final String QINIU_REALTIME_PLUS = "qwen3.5-omni-plus-realtime"; public static final String QWEN_LLM_PLUS = "qwen3.5-plus"; public static final String DEEPSEEK_CHAT = "deepseek-v4-flash"; + public static final String QINIU_MAAS_DEEPSEEK_FLASH = "deepseek/deepseek-v4-flash"; + public static final String QINIU_MAAS_QWEN_PLUS = "qwen/qwen3.5-plus"; public static final String QWEN_ASR = "qwen3-asr-flash"; public static final String DOUBAO_ASR = "volc.bigasr.auc_turbo"; public static final String IFLYTEK_PRONUNCIATION_SCORING = "iflytek-suntone"; @@ -48,14 +50,14 @@ public record RoutedResult( private static final Map> DEFAULT_MODEL_ROUTES = Map.of( AiCapability.REALTIME, List.of(QINIU_REALTIME_PLUS, QWEN_REALTIME_FLASH), - AiCapability.LLM, List.of(QWEN_LLM_PLUS, DEEPSEEK_CHAT), + AiCapability.LLM, List.of(QINIU_MAAS_QWEN_PLUS, QWEN_LLM_PLUS), AiCapability.SCORING, List.of(IFLYTEK_PRONUNCIATION_SCORING), AiCapability.TTS, List.of(QWEN_TTS, ALIYUN_TTS, MINIMAX_TTS), AiCapability.TRANSCRIPTION, List.of(QWEN_ASR, DOUBAO_ASR)); private static final Map> DEFAULT_PROVIDER_ROUTES = Map.of( AiCapability.REALTIME, List.of("qiniu", "qwen"), - AiCapability.LLM, List.of("qwen", "deepseek"), + AiCapability.LLM, List.of("qiniu-maas", "qwen"), AiCapability.SCORING, List.of("iflytek"), AiCapability.TTS, List.of("qwen", "aliyun", "minimax"), AiCapability.TRANSCRIPTION, List.of("qwen", "doubao")); @@ -368,6 +370,13 @@ private List defaultRoute( for (String providerId : DEFAULT_PROVIDER_ROUTES.getOrDefault(capability, List.of())) { addProviderModelsIfAbsent(route, registeredProviders, providerId); } + // The LLM default route is intentionally limited to Qiniu MaaS Qwen and + // Alibaba Qwen. Do not append unrelated legacy providers after that route + // has been established; explicit configured routes still remain untouched. + if (capability == AiCapability.LLM + && route.contains(QINIU_MAAS_QWEN_PLUS)) { + return List.copyOf(route); + } for (AbstractAiProvider provider : registeredProviders.values()) { addProviderModelsIfAbsent(route, registeredProviders, provider.providerId()); } @@ -434,14 +443,23 @@ private T invokeModels( BusinessException lastFailure = null; for (int index = 0; index < models.size(); index++) { String modelId = models.get(index); + AiModelDefinition definition = getModel(modelId); + long startedAt = System.nanoTime(); + LOGGER.info( + "AI provider attempt capability={} model={} provider={} attempt={}/{}", + capability, + definition.modelId(), + definition.providerId(), + index + 1, + models.size()); try { T response = operation.apply(modelId); - AiModelDefinition definition = getModel(modelId); LOGGER.info( - "AI provider selected capability={} model={} provider={}", + "AI provider selected capability={} model={} provider={} durationMs={}", capability, definition.modelId(), - definition.providerId()); + definition.providerId(), + elapsedMillis(startedAt)); return response; } catch (BusinessException exception) { @@ -451,12 +469,23 @@ private T invokeModels( lastFailure = exception; if (index + 1 < models.size()) { LOGGER.warn( - "AI provider failover capability={} failedModel={} errorCode={} nextModel={}", + "AI provider failover capability={} failedModel={} provider={} durationMs={} errorCode={} nextModel={}", capability, modelId, + definition.providerId(), + elapsedMillis(startedAt), exception.code(), models.get(index + 1)); } + else { + LOGGER.warn( + "AI provider route exhausted capability={} failedModel={} provider={} durationMs={} errorCode={}", + capability, + modelId, + definition.providerId(), + elapsedMillis(startedAt), + exception.code()); + } } } if (lastFailure != null) { @@ -467,6 +496,10 @@ private T invokeModels( "No AI provider completed the " + capability + " request"); } + private static long elapsedMillis(long startedAt) { + return (System.nanoTime() - startedAt) / 1_000_000; + } + private boolean shouldFailOver(BusinessException exception) { Boolean classifiedRetryable = AbstractAiProvider.retryable(exception); if (classifiedRetryable != null) { diff --git a/backend/unispeaking-server/src/main/java/com/unispeaking/service/scene/CustomSceneService.java b/backend/unispeaking-server/src/main/java/com/unispeaking/service/scene/CustomSceneService.java index 8639edc0..a64f136e 100644 --- a/backend/unispeaking-server/src/main/java/com/unispeaking/service/scene/CustomSceneService.java +++ b/backend/unispeaking-server/src/main/java/com/unispeaking/service/scene/CustomSceneService.java @@ -113,10 +113,7 @@ public TranslateTextResponse translate(String sceneId, String text) { %s """.formatted(source); - String translated = providerRegistry.executeLlmTask( - AiProviderRegistry.QWEN_LLM_PLUS, - prompt, - null); + String translated = providerRegistry.executeLlmTask(prompt, null); if (translated == null || translated.isBlank()) { throw new BusinessException("TRANSLATION_EMPTY", "翻译模型没有返回有效文本"); } diff --git a/backend/unispeaking-server/src/main/java/com/unispeaking/service/scene/FreeChatSceneService.java b/backend/unispeaking-server/src/main/java/com/unispeaking/service/scene/FreeChatSceneService.java index 30f74834..056a2d99 100644 --- a/backend/unispeaking-server/src/main/java/com/unispeaking/service/scene/FreeChatSceneService.java +++ b/backend/unispeaking-server/src/main/java/com/unispeaking/service/scene/FreeChatSceneService.java @@ -88,10 +88,7 @@ public TranslateTextResponse translate(String text) { %s """.formatted(source); - String translated = providerRegistry.executeLlmTask( - AiProviderRegistry.QWEN_LLM_PLUS, - prompt, - null); + String translated = providerRegistry.executeLlmTask(prompt, null); if (translated == null || translated.isBlank()) { throw new BusinessException( "TRANSLATION_EMPTY", diff --git a/backend/unispeaking-server/src/main/resources/application.yaml b/backend/unispeaking-server/src/main/resources/application.yaml index 30b43e75..baf5e0db 100644 --- a/backend/unispeaking-server/src/main/resources/application.yaml +++ b/backend/unispeaking-server/src/main/resources/application.yaml @@ -126,6 +126,17 @@ realtime: read-timeout: ${QINIU_RTI_READ_TIMEOUT:20s} max-response-bytes: ${QINIU_RTI_MAX_RESPONSE_BYTES:1048576} +ai: + qiniu-maas: + base-url: ${QINIU_MAAS_BASE_URL:https://api.qnaigc.com/v1} + api-key: ${QINIU_MAAS_API_KEY:} + primary-model: ${QINIU_MAAS_PRIMARY_MODEL:qwen/qwen3.5-plus} + fallback-model: ${QINIU_MAAS_FALLBACK_MODEL:deepseek/deepseek-v4-flash} + connect-timeout: ${QINIU_MAAS_CONNECT_TIMEOUT:10s} + read-timeout: ${QINIU_MAAS_READ_TIMEOUT:90s} + max-response-bytes: ${QINIU_MAAS_MAX_RESPONSE_BYTES:2097152} + max-output-tokens: ${QINIU_MAAS_MAX_OUTPUT_TOKENS:4096} + gateway: enabled: ${GATEWAY_ENABLED:false} credential-ttl-seconds: ${GATEWAY_CREDENTIAL_TTL_SECONDS:300} diff --git a/backend/unispeaking-server/src/test/java/com/unispeaking/infrastructure/ai/qiniu/QiniuMaasLlmProviderTest.java b/backend/unispeaking-server/src/test/java/com/unispeaking/infrastructure/ai/qiniu/QiniuMaasLlmProviderTest.java new file mode 100644 index 00000000..3c5472a2 --- /dev/null +++ b/backend/unispeaking-server/src/test/java/com/unispeaking/infrastructure/ai/qiniu/QiniuMaasLlmProviderTest.java @@ -0,0 +1,204 @@ +package com.unispeaking.infrastructure.ai.qiniu; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import ch.qos.logback.classic.Logger; +import ch.qos.logback.classic.spi.ILoggingEvent; +import ch.qos.logback.core.read.ListAppender; +import com.sun.net.httpserver.HttpServer; +import com.unispeaking.common.exception.BusinessException; +import com.unispeaking.infrastructure.config.QiniuMaasProperties; +import java.io.IOException; +import java.net.InetSocketAddress; +import java.net.http.HttpClient; +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.util.concurrent.atomic.AtomicReference; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import org.slf4j.LoggerFactory; +import tools.jackson.databind.ObjectMapper; + +class QiniuMaasLlmProviderTest { + + private HttpServer server; + + @AfterEach + void stopServer() { + if (server != null) server.stop(0); + } + + @Test + void sendsAnOpenAiCompatibleNonStreamingRequest() throws IOException { + AtomicReference authorization = new AtomicReference<>(); + AtomicReference requestBody = new AtomicReference<>(); + server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); + server.createContext("/v1/chat/completions", exchange -> { + authorization.set(exchange.getRequestHeaders().getFirst("Authorization")); + requestBody.set(new String( + exchange.getRequestBody().readAllBytes(), + StandardCharsets.UTF_8)); + byte[] response = """ + {"choices":[{"message":{"content":"ok"}}]} + """.getBytes(StandardCharsets.UTF_8); + exchange.sendResponseHeaders(200, response.length); + exchange.getResponseBody().write(response); + exchange.close(); + }); + server.start(); + + QiniuMaasProperties properties = properties( + "secret-key", + "deepseek/deepseek-v4-flash"); + QiniuMaasLlmClient client = new QiniuMaasLlmClient( + HttpClient.newHttpClient(), + new ObjectMapper(), + properties, + java.net.URI.create( + "http://127.0.0.1:" + server.getAddress().getPort() + + "/v1/chat/completions")); + QiniuMaasLlmProvider provider = new QiniuMaasLlmProvider( + client, + properties.primaryModel()); + + String response = provider.executeLlmTask("Return JSON.", null); + + assertEquals("ok", response); + assertEquals("Bearer secret-key", authorization.get()); + assertTrue(requestBody.get().contains("\"model\":\"deepseek/deepseek-v4-flash\"")); + assertTrue(requestBody.get().contains("\"content\":\"Return JSON.\"")); + assertTrue(requestBody.get().contains("\"stream\":false")); + assertTrue(requestBody.get().contains("\"max_tokens\":4096")); + assertFalse(requestBody.get().contains("secret-key")); + assertFalse(requestBody.get().contains("thinking")); + } + + @Test + void reportsMissingCredentialsWithoutSendingARequest() { + QiniuMaasProperties properties = properties("", "deepseek/deepseek-v4-flash"); + QiniuMaasLlmProvider provider = new QiniuMaasLlmProvider( + new QiniuMaasLlmClient( + HttpClient.newHttpClient(), + new ObjectMapper(), + properties), + properties.primaryModel()); + + BusinessException exception = assertThrows( + BusinessException.class, + () -> provider.executeLlmTask("hello", null)); + + assertEquals("QINIU_MAAS_CREDENTIAL_MISSING", exception.code()); + } + + @Test + void rejectsBlankPromptsAsNonRetryableInputFailures() { + QiniuMaasProperties properties = properties("secret-key", "qwen/qwen3.5-plus"); + QiniuMaasLlmProvider provider = new QiniuMaasLlmProvider( + new QiniuMaasLlmClient( + HttpClient.newHttpClient(), + new ObjectMapper(), + properties), + properties.primaryModel()); + + BusinessException exception = assertThrows( + BusinessException.class, + () -> provider.executeLlmTask(" ", null)); + + assertEquals("INVALID_LLM_PROMPT", exception.code()); + } + + @Test + void mapsAuthenticationFailuresWithoutExposingTheApiKey() throws IOException { + server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); + server.createContext("/v1/chat/completions", exchange -> { + byte[] response = "{\"error\":{\"message\":\"invalid api key\"}}" + .getBytes(StandardCharsets.UTF_8); + exchange.sendResponseHeaders(401, response.length); + exchange.getResponseBody().write(response); + exchange.close(); + }); + server.start(); + QiniuMaasProperties properties = properties( + "secret-key", + "deepseek/deepseek-v4-flash"); + QiniuMaasLlmProvider provider = new QiniuMaasLlmProvider( + new QiniuMaasLlmClient( + HttpClient.newHttpClient(), + new ObjectMapper(), + properties, + java.net.URI.create( + "http://127.0.0.1:" + server.getAddress().getPort() + + "/v1/chat/completions")), + properties.primaryModel()); + + BusinessException exception = assertThrows( + BusinessException.class, + () -> provider.executeLlmTask("hello", null)); + + assertEquals("QINIU_MAAS_LLM_REQUEST_FAILED", exception.code()); + assertFalse(exception.getMessage().contains("secret-key")); + } + + @Test + void logsTimingMetadataWithoutCredentialsOrPromptContent() throws IOException { + server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); + server.createContext("/v1/chat/completions", exchange -> { + byte[] response = "{\"choices\":[{\"message\":{\"content\":\"ok\"}}]}" + .getBytes(StandardCharsets.UTF_8); + exchange.sendResponseHeaders(200, response.length); + exchange.getResponseBody().write(response); + exchange.close(); + }); + server.start(); + QiniuMaasProperties properties = properties( + "secret-key", + "deepseek/deepseek-v4-flash"); + QiniuMaasLlmProvider provider = new QiniuMaasLlmProvider( + new QiniuMaasLlmClient( + HttpClient.newHttpClient(), + new ObjectMapper(), + properties, + java.net.URI.create( + "http://127.0.0.1:" + server.getAddress().getPort() + + "/v1/chat/completions")), + properties.primaryModel()); + Logger logger = (Logger) LoggerFactory.getLogger(QiniuMaasLlmClient.class); + ListAppender appender = new ListAppender<>(); + appender.start(); + logger.addAppender(appender); + + try { + provider.executeLlmTask("private prompt content", null); + } + finally { + logger.detachAppender(appender); + } + + String logs = appender.list.stream() + .map(ILoggingEvent::getFormattedMessage) + .collect(java.util.stream.Collectors.joining("\n")); + assertTrue(logs.contains("request started model=deepseek/deepseek-v4-flash")); + assertTrue(logs.contains("request completed model=deepseek/deepseek-v4-flash")); + assertTrue(logs.contains("durationMs=")); + assertTrue(logs.contains("responseChars=2")); + assertFalse(logs.contains("secret-key")); + assertFalse(logs.contains("private prompt content")); + } + + private QiniuMaasProperties properties(String apiKey, String primaryModel) { + return new QiniuMaasProperties( + "https://api.qnaigc.com/v1", + apiKey, + primaryModel, + primaryModel.equals("qwen/qwen3.5-plus") + ? "deepseek/deepseek-v4-flash" + : "qwen/qwen3.5-plus", + Duration.ofSeconds(10), + Duration.ofSeconds(90), + 2_097_152, + 4096); + } +} diff --git a/backend/unispeaking-server/src/test/java/com/unispeaking/infrastructure/config/QiniuMaasPropertiesTest.java b/backend/unispeaking-server/src/test/java/com/unispeaking/infrastructure/config/QiniuMaasPropertiesTest.java new file mode 100644 index 00000000..531efce0 --- /dev/null +++ b/backend/unispeaking-server/src/test/java/com/unispeaking/infrastructure/config/QiniuMaasPropertiesTest.java @@ -0,0 +1,56 @@ +package com.unispeaking.infrastructure.config; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import java.time.Duration; +import org.junit.jupiter.api.Test; + +class QiniuMaasPropertiesTest { + + @Test + void acceptsBothDocumentedQiniuMaasEndpoints() { + QiniuMaasProperties current = properties("https://api.qnaigc.com/v1"); + QiniuMaasProperties legacy = properties("https://openai.sufy.com/v1/"); + + current.validate(); + legacy.validate(); + assertEquals( + "https://openai.sufy.com/v1/chat/completions", + legacy.chatCompletionsUri().toString()); + } + + @Test + void rejectsUntrustedEndpointsBeforeCredentialsCanBeSent() { + QiniuMaasProperties properties = properties("https://evil.example/v1"); + + assertThrows(IllegalStateException.class, properties::validate); + } + + @Test + void rejectsDuplicatePrimaryAndFallbackModels() { + QiniuMaasProperties properties = new QiniuMaasProperties( + "https://api.qnaigc.com/v1", + "key", + "same-model", + "same-model", + Duration.ofSeconds(10), + Duration.ofSeconds(90), + 2_097_152, + 4096); + + assertThrows(IllegalStateException.class, properties::validate); + } + + private QiniuMaasProperties properties(String baseUrl) { + return new QiniuMaasProperties( + baseUrl, + "key", + "deepseek/deepseek-v4-flash", + "qwen/qwen3.5-plus", + Duration.ofSeconds(10), + Duration.ofSeconds(90), + 2_097_152, + 4096); + } +} diff --git a/backend/unispeaking-server/src/test/java/com/unispeaking/provider/AiProviderRegistryTest.java b/backend/unispeaking-server/src/test/java/com/unispeaking/provider/AiProviderRegistryTest.java index 771a74af..10c5c472 100644 --- a/backend/unispeaking-server/src/test/java/com/unispeaking/provider/AiProviderRegistryTest.java +++ b/backend/unispeaking-server/src/test/java/com/unispeaking/provider/AiProviderRegistryTest.java @@ -5,6 +5,9 @@ import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; +import ch.qos.logback.classic.Logger; +import ch.qos.logback.classic.spi.ILoggingEvent; +import ch.qos.logback.core.read.ListAppender; import com.unispeaking.domain.vo.provider.AiCapability; import com.unispeaking.domain.vo.provider.AiModelDefinition; import com.unispeaking.domain.vo.provider.ProviderType; @@ -13,6 +16,7 @@ import java.util.Map; import java.util.Set; import org.junit.jupiter.api.Test; +import org.slf4j.LoggerFactory; class AiProviderRegistryTest { @@ -44,7 +48,9 @@ void selectsProvidersByCapabilityAndModel() { assertEquals( AiProviderRegistry.QINIU_REALTIME_PLUS, registry.defaultModel(AiCapability.REALTIME)); - assertEquals(AiProviderRegistry.QWEN_LLM_PLUS, registry.defaultModel(AiCapability.LLM)); + assertEquals( + AiProviderRegistry.QINIU_MAAS_QWEN_PLUS, + registry.defaultModel(AiCapability.LLM)); assertSame( qiniu, registry.getRealtimeProvider(AiProviderRegistry.QINIU_REALTIME_PLUS)); @@ -94,6 +100,44 @@ void routesAFeatureCallThroughTheConfiguredPrimaryModel() { assertEquals(AiProviderRegistry.DEEPSEEK_CHAT, registry.defaultModel(AiCapability.LLM)); } + @Test + void failsOverFromQiniuQwenToAlibabaQwen() { + FailingQiniuMaasLlmProvider primary = new FailingQiniuMaasLlmProvider( + AiProviderRegistry.QINIU_MAAS_QWEN_PLUS); + StubQwenLlmProvider alibabaQwen = new StubQwenLlmProvider(); + StubDeepSeekLlmProvider legacyDeepSeek = new StubDeepSeekLlmProvider(); + AiProviderRegistry registry = registry( + List.of(primary, alibabaQwen, legacyDeepSeek), + Map.of( + AiCapability.LLM, + List.of( + AiProviderRegistry.QINIU_MAAS_QWEN_PLUS, + AiProviderRegistry.QWEN_LLM_PLUS))); + + Logger logger = (Logger) LoggerFactory.getLogger(AiProviderRegistry.class); + ListAppender appender = new ListAppender<>(); + appender.start(); + logger.addAppender(appender); + String response; + try { + response = registry.executeLlmTask("hello", null); + } + finally { + logger.detachAppender(appender); + } + + assertEquals("qwen", response); + assertEquals(1, alibabaQwen.calls); + assertEquals(0, legacyDeepSeek.calls); + String logs = appender.list.stream() + .map(ILoggingEvent::getFormattedMessage) + .collect(java.util.stream.Collectors.joining("\n")); + assertTrue(logs.contains("AI provider attempt capability=LLM")); + assertTrue(logs.contains("AI provider failover capability=LLM")); + assertTrue(logs.contains("durationMs=")); + assertTrue(logs.contains("nextModel=qwen3.5-plus")); + } + @Test void preservesProviderOrderWhenConfiguredModelsReplaceDefaultModelNames() { LlmProvider qwen = new ConfiguredModelLlmProvider("qwen", "qwen-custom-llm"); @@ -112,7 +156,7 @@ void preservesProviderOrderWhenConfiguredModelsReplaceDefaultModelNames() { } @Test - void usesQiniuAsThePrimaryRealtimeModelAndQwenForOtherGenerativeCapabilities() { + void usesQiniuAsThePrimaryRealtimeAndLlmProvider() { AiProviderRegistry registry = realtimeRegistry( new StubQiniuRealtimeProvider(), new StubRealtimeProvider()); @@ -120,7 +164,7 @@ void usesQiniuAsThePrimaryRealtimeModelAndQwenForOtherGenerativeCapabilities() { AiProviderRegistry.QINIU_REALTIME_PLUS, registry.defaultModel(AiCapability.REALTIME)); assertEquals( - AiProviderRegistry.QWEN_LLM_PLUS, + AiProviderRegistry.QINIU_MAAS_QWEN_PLUS, registry.defaultModel(AiCapability.LLM)); assertEquals( StubTranscriptionProvider.MODEL_ID, @@ -131,6 +175,11 @@ void usesQiniuAsThePrimaryRealtimeModelAndQwenForOtherGenerativeCapabilities() { assertEquals( AiProviderRegistry.IFLYTEK_PRONUNCIATION_SCORING, registry.defaultModel(AiCapability.SCORING)); + assertEquals( + List.of( + AiProviderRegistry.QINIU_MAAS_QWEN_PLUS, + AiProviderRegistry.QWEN_LLM_PLUS), + registry.route(AiCapability.LLM)); } @Test @@ -240,7 +289,11 @@ private AiProviderRegistry registry( } private List llmProviders() { - return List.of(new StubQwenLlmProvider(), new StubDeepSeekLlmProvider()); + return List.of( + new StubQiniuMaasLlmProvider(AiProviderRegistry.QINIU_MAAS_DEEPSEEK_FLASH), + new StubQiniuMaasLlmProvider(AiProviderRegistry.QINIU_MAAS_QWEN_PLUS), + new StubQwenLlmProvider(), + new StubDeepSeekLlmProvider()); } private List ttsProviders() { @@ -312,6 +365,30 @@ public String executeLlmTask(String prompt, String token) { } } + private static final class StubQiniuMaasLlmProvider extends LlmProvider { + + private StubQiniuMaasLlmProvider(String model) { + super("qiniu-maas", Set.of(model)); + } + + @Override + public String executeLlmTask(String prompt, String token) { + return "qiniu-maas"; + } + } + + private static final class FailingQiniuMaasLlmProvider extends LlmProvider { + + private FailingQiniuMaasLlmProvider(String model) { + super("qiniu-maas", Set.of(model)); + } + + @Override + public String executeLlmTask(String prompt, String token) { + throw new BusinessException("QINIU_MAAS_LLM_IO_ERROR", "unavailable"); + } + } + private static final class ConfiguredModelLlmProvider extends LlmProvider { private ConfiguredModelLlmProvider(String providerId, String modelId) { diff --git a/backend/unispeaking-server/src/test/java/com/unispeaking/service/scene/CustomSceneGeneratorTest.java b/backend/unispeaking-server/src/test/java/com/unispeaking/service/scene/CustomSceneGeneratorTest.java index a08351d5..6bd86e18 100644 --- a/backend/unispeaking-server/src/test/java/com/unispeaking/service/scene/CustomSceneGeneratorTest.java +++ b/backend/unispeaking-server/src/test/java/com/unispeaking/service/scene/CustomSceneGeneratorTest.java @@ -9,6 +9,9 @@ import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; +import ch.qos.logback.classic.Logger; +import ch.qos.logback.classic.spi.ILoggingEvent; +import ch.qos.logback.core.read.ListAppender; import com.unispeaking.domain.po.profile.UserProfile; import com.unispeaking.provider.AiProviderRegistry; import com.unispeaking.component.scene.CustomSceneGenerator; @@ -18,6 +21,7 @@ import java.util.Map; import org.junit.jupiter.api.Test; import org.mockito.ArgumentCaptor; +import org.slf4j.LoggerFactory; import tools.jackson.databind.JsonNode; import tools.jackson.databind.ObjectMapper; @@ -82,19 +86,38 @@ void generatesCompactLearningContentAndMachineReadableSuccessFactor() { @Test void retriesWhenFirstResponseHasTooFewWords() { AiProviderRegistry registry = mock(AiProviderRegistry.class); + String rejectedResponse = validResponse(3); when(registry.executeLlmTask(anyString(), isNull())) - .thenReturn(validResponse(3), validResponse(5)); + .thenReturn(rejectedResponse, validResponse(5)); var service = new CustomSceneGenerator(registry, objectMapper); - - var scene = service.generate( - "custom_retry", - "user-1", - "餐厅处理点餐错误", - null, - new UserProfile("user-1", "C", "Katerina", "zh-CN", "")); + Logger logger = (Logger) LoggerFactory.getLogger(CustomSceneGenerator.class); + ListAppender appender = new ListAppender<>(); + appender.start(); + logger.addAppender(appender); + + com.unispeaking.domain.po.scene.CustomSceneDefinition scene; + try { + scene = service.generate( + "custom_retry", + "user-1", + "餐厅处理点餐错误", + null, + new UserProfile("user-1", "C", "Katerina", "zh-CN", "")); + } + finally { + logger.detachAppender(appender); + } assertEquals(5, scene.wordList().size()); verify(registry, times(2)).executeLlmTask(anyString(), isNull()); + String logs = appender.list.stream() + .map(ILoggingEvent::getFormattedMessage) + .collect(java.util.stream.Collectors.joining("\n")); + assertTrue(logs.contains("response rejected sceneId=custom_retry attempt=1")); + assertTrue(logs.contains("llmMs=")); + assertTrue(logs.contains("parseMs=")); + assertTrue(logs.contains("responseChars=" + rejectedResponse.length())); + assertTrue(!logs.contains(rejectedResponse)); } @Test diff --git a/backend/unispeaking-server/src/test/java/com/unispeaking/service/scene/LlmTranslationRoutingTest.java b/backend/unispeaking-server/src/test/java/com/unispeaking/service/scene/LlmTranslationRoutingTest.java new file mode 100644 index 00000000..8960a6a0 --- /dev/null +++ b/backend/unispeaking-server/src/test/java/com/unispeaking/service/scene/LlmTranslationRoutingTest.java @@ -0,0 +1,80 @@ +package com.unispeaking.service.scene; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.isNull; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.unispeaking.common.prompt.FiveLayerPromptBuilder; +import com.unispeaking.component.scene.CustomSceneGenerator; +import com.unispeaking.domain.po.scene.CustomSceneDefinition; +import com.unispeaking.infrastructure.persistence.repository.scene.SceneRepository; +import com.unispeaking.provider.AiProviderRegistry; +import com.unispeaking.service.auth.AuthService; +import com.unispeaking.service.profile.ProfileService; +import java.util.List; +import java.util.Optional; +import org.junit.jupiter.api.Test; +import tools.jackson.databind.ObjectMapper; + +class LlmTranslationRoutingTest { + + @Test + void freeChatTranslationUsesTheConfiguredLlmRoute() { + AuthService authService = mock(AuthService.class); + AiProviderRegistry registry = mock(AiProviderRegistry.class); + when(registry.executeLlmTask(anyString(), isNull())).thenReturn("你好"); + FreeChatSceneService service = new FreeChatSceneService( + authService, + mock(ProfileService.class), + mock(SceneRepository.class), + mock(FiveLayerPromptBuilder.class), + registry); + + var response = service.translate("Hello"); + + assertEquals("你好", response.translatedText()); + verify(registry).executeLlmTask(anyString(), isNull()); + } + + @Test + void customSceneTranslationUsesTheConfiguredLlmRoute() { + String userId = "11111111-1111-4111-8111-111111111111"; + String sceneId = "custom_translation"; + AuthService authService = mock(AuthService.class); + SceneRepository repository = mock(SceneRepository.class); + AiProviderRegistry registry = mock(AiProviderRegistry.class); + when(authService.requireUserId(null)).thenReturn(userId); + when(repository.findCustomDefinitionById(sceneId)).thenReturn(Optional.of( + new CustomSceneDefinition( + sceneId, + userId, + "title", + "label", + "background", + "assistant", + "learner", + "goal", + "instruction", + "{}", + List.of(), + List.of(), + List.of()))); + when(registry.executeLlmTask(anyString(), isNull())).thenReturn("你好"); + CustomSceneService service = new CustomSceneService( + authService, + mock(ProfileService.class), + repository, + mock(FiveLayerPromptBuilder.class), + mock(CustomSceneGenerator.class), + registry, + new ObjectMapper()); + + var response = service.translate(sceneId, "Hello"); + + assertEquals("你好", response.translatedText()); + verify(registry).executeLlmTask(anyString(), isNull()); + } +} diff --git a/deploy/env/.env.example b/deploy/env/.env.example index 093666ce..db6dc291 100644 --- a/deploy/env/.env.example +++ b/deploy/env/.env.example @@ -112,7 +112,7 @@ OCR_TIMEOUT=120s # If a model ID below is changed, update the corresponding route as well. # ============================================================================= AI_PROVIDER_ROUTE_REALTIME=qwen3.5-omni-plus-realtime,qwen3.5-omni-flash-realtime -AI_PROVIDER_ROUTE_LLM=qwen3.5-plus,deepseek-v4-flash +AI_PROVIDER_ROUTE_LLM=qwen/qwen3.5-plus,qwen3.5-plus AI_PROVIDER_ROUTE_TRANSCRIPTION=qwen3-asr-flash,volc.bigasr.auc_turbo AI_PROVIDER_ROUTE_TTS=qwen3-tts-flash AI_PROVIDER_ROUTE_SCORING=iflytek-suntone @@ -164,14 +164,28 @@ QINIU_RTI_READ_TIMEOUT=20s QINIU_RTI_MAX_RESPONSE_BYTES=1048576 # ============================================================================= -# Qwen LLM - primary +# Qiniu MaaS LLM - primary and fallback +# Keep the permanent API key on the backend. The base URL must be one of the +# trusted Qiniu OpenAI-compatible v1 endpoints. +# ============================================================================= +QINIU_MAAS_BASE_URL=https://api.qnaigc.com/v1 +QINIU_MAAS_API_KEY= +QINIU_MAAS_PRIMARY_MODEL=qwen/qwen3.5-plus +QINIU_MAAS_FALLBACK_MODEL=deepseek/deepseek-v4-flash +QINIU_MAAS_CONNECT_TIMEOUT=10s +QINIU_MAAS_READ_TIMEOUT=90s +QINIU_MAAS_MAX_RESPONSE_BYTES=2097152 +QINIU_MAAS_MAX_OUTPUT_TOKENS=4096 + +# ============================================================================= +# Legacy direct LLM providers - explicit rollback only # ============================================================================= QWEN_LLM_MODEL=qwen3.5-plus QWEN_LLM_CONNECT_TIMEOUT_SECONDS=10 QWEN_LLM_READ_TIMEOUT_SECONDS=60 QWEN_LLM_MAX_RESPONSE_BYTES=2097152 -# DeepSeek LLM - fallback +# DeepSeek direct LLM DEEPSEEK_API_KEY= DEEPSEEK_LLM_ENDPOINT=https://api.deepseek.com/chat/completions DEEPSEEK_LLM_MODEL=deepseek-v4-flash diff --git a/deploy/env/.env.prod.example b/deploy/env/.env.prod.example index 5604249f..9c0c1e13 100644 --- a/deploy/env/.env.prod.example +++ b/deploy/env/.env.prod.example @@ -117,7 +117,7 @@ OCR_TIMEOUT=120s # If a model ID below is changed, update the corresponding route as well. # ============================================================================= AI_PROVIDER_ROUTE_REALTIME=qwen3.5-omni-plus-realtime,qwen3.5-omni-flash-realtime -AI_PROVIDER_ROUTE_LLM=qwen3.5-plus,deepseek-v4-flash +AI_PROVIDER_ROUTE_LLM=qwen/qwen3.5-plus,qwen3.5-plus AI_PROVIDER_ROUTE_TRANSCRIPTION=qwen3-asr-flash,volc.bigasr.auc_turbo AI_PROVIDER_ROUTE_TTS=qwen3-tts-flash AI_PROVIDER_ROUTE_SCORING=iflytek-suntone @@ -164,14 +164,26 @@ REALTIME_QWEN_READ_TIMEOUT=20s REALTIME_QWEN_MAX_ANSWER_BYTES=1048576 # ============================================================================= -# Qwen LLM - primary +# Qiniu MaaS LLM - primary and fallback. Keep the API key server-side. +# ============================================================================= +QINIU_MAAS_BASE_URL=https://api.qnaigc.com/v1 +QINIU_MAAS_API_KEY=replace-with-qiniu-maas-api-key +QINIU_MAAS_PRIMARY_MODEL=qwen/qwen3.5-plus +QINIU_MAAS_FALLBACK_MODEL=deepseek/deepseek-v4-flash +QINIU_MAAS_CONNECT_TIMEOUT=10s +QINIU_MAAS_READ_TIMEOUT=90s +QINIU_MAAS_MAX_RESPONSE_BYTES=2097152 +QINIU_MAAS_MAX_OUTPUT_TOKENS=4096 + +# ============================================================================= +# Legacy direct LLM providers - explicit rollback only # ============================================================================= QWEN_LLM_MODEL=qwen3.5-plus QWEN_LLM_CONNECT_TIMEOUT_SECONDS=10 QWEN_LLM_READ_TIMEOUT_SECONDS=60 QWEN_LLM_MAX_RESPONSE_BYTES=2097152 -# DeepSeek LLM - fallback +# DeepSeek direct LLM DEEPSEEK_API_KEY= DEEPSEEK_LLM_ENDPOINT=https://api.deepseek.com/chat/completions DEEPSEEK_LLM_MODEL=deepseek-v4-flash diff --git a/docs/deployment-production.md b/docs/deployment-production.md index 8954b33b..ce79152e 100644 --- a/docs/deployment-production.md +++ b/docs/deployment-production.md @@ -33,6 +33,24 @@ nano deploy/env/.env Replace every credential placeholder. Keep `DATABASE_URL` pointed at the Compose service name `postgres`, not `localhost`. +### Configure Qiniu MaaS LLM + +Set the permanent MaaS credential and keep the default two-model route in +`deploy/env/.env`: + +```dotenv +QINIU_MAAS_BASE_URL=https://api.qnaigc.com/v1 +QINIU_MAAS_API_KEY=replace-with-qiniu-maas-api-key +QINIU_MAAS_PRIMARY_MODEL=qwen/qwen3.5-plus +QINIU_MAAS_FALLBACK_MODEL=deepseek/deepseek-v4-flash +AI_PROVIDER_ROUTE_LLM=qwen/qwen3.5-plus,qwen3.5-plus +``` + +The alternative trusted base URL is `https://openai.sufy.com/v1`. Do not put +the MaaS API key in a `VITE_` variable, command output, image build argument, or +committed file. Keep the existing DashScope and DeepSeek credentials only when +their direct providers are needed for an explicit rollback. + ### Enable Umami Cloud for the migrated Web domain The Web tracker is disabled by default. For the current migrated frontend at @@ -154,6 +172,19 @@ docker compose --env-file deploy/env/.env \ Visit `https://unispeaking.cn` and verify registration, login, microphone permission, WebSocket sessions, IELTS topics, and audio features. +Then verify the LLM migration with one request from each business path: + +- translate a FreeChat subtitle; +- generate and translate a custom scene; +- generate IELTS text evaluation/report content while confirming that iFlytek + pronunciation scoring is unchanged; +- prepare interview material, advance topics, and generate the report. + +Confirm that Qiniu MaaS records the requests, application logs identify +`capability=LLM provider=qiniu-maas`, and the direct DashScope/DeepSeek accounts +do not record new LLM calls. Realtime voice sessions, ASR, TTS, and iFlytek +scoring must continue to use their existing routes. + ## Backups Run the backup script manually once: diff --git a/docs/deployment.md b/docs/deployment.md index 6c0cf270..e8da5666 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -14,7 +14,7 @@ The repository contains `deploy/env/.env.example`. The working copy also uses `deploy/env/.env`, which is ignored by Git. Set the following variables in `deploy/env/.env` before starting a realtime -session or using profile avatars: +session, invoking an LLM, or using profile avatars: ```properties DASHSCOPE_API_KEY=replace-with-your-real-key @@ -22,6 +22,8 @@ BAILIAN_WORKSPACE_ID=replace-with-your-workspace-id BAILIAN_MODEL=qwen3.5-omni-flash-realtime QINIU_RTI_API_KEY=replace-with-your-qiniu-rti-api-key QINIU_RTI_APP_ID=unispeaking_001 +QINIU_MAAS_API_KEY=replace-with-your-qiniu-maas-api-key +QINIU_MAAS_BASE_URL=https://api.qnaigc.com/v1 QINIU_ACCESS_KEY=replace-with-your-access-key QINIU_SECRET_KEY=replace-with-your-secret-key QINIU_BUCKET=replace-with-your-private-bucket @@ -176,6 +178,17 @@ realtime: read-timeout: ${REALTIME_QWEN_READ_TIMEOUT:20s} max-answer-bytes: ${REALTIME_QWEN_MAX_ANSWER_BYTES:1048576} +ai: + qiniu-maas: + base-url: ${QINIU_MAAS_BASE_URL:https://api.qnaigc.com/v1} + api-key: ${QINIU_MAAS_API_KEY:} + primary-model: ${QINIU_MAAS_PRIMARY_MODEL:qwen/qwen3.5-plus} + fallback-model: ${QINIU_MAAS_FALLBACK_MODEL:deepseek/deepseek-v4-flash} + connect-timeout: ${QINIU_MAAS_CONNECT_TIMEOUT:10s} + read-timeout: ${QINIU_MAAS_READ_TIMEOUT:90s} + max-response-bytes: ${QINIU_MAAS_MAX_RESPONSE_BYTES:2097152} + max-output-tokens: ${QINIU_MAAS_MAX_OUTPUT_TOKENS:4096} + profile: time-zone: ${PROFILE_TIME_ZONE:Asia/Shanghai} @@ -195,6 +208,24 @@ The default realtime route is: AI_PROVIDER_ROUTE_REALTIME=qwen3.5-omni-plus-realtime,qwen3.5-omni-flash-realtime ``` +The default LLM route uses Qiniu MaaS first and falls back to Alibaba Cloud Qwen +when the first request fails with a retryable provider error: + +```properties +AI_PROVIDER_ROUTE_LLM=qwen/qwen3.5-plus,qwen3.5-plus +``` + +The backend sends OpenAI-compatible `POST /v1/chat/completions` requests. It +accepts only `https://api.qnaigc.com/v1` and `https://openai.sufy.com/v1` as +MaaS base URLs. Authentication failures (`401` and `403`) stop the route so a +bad credential is not hidden; rate limits, server failures, I/O failures, and +invalid or empty responses may fall back to Alibaba Cloud Qwen. The legacy +Qwen and DeepSeek direct providers remain available only when an operator +explicitly configures their model IDs in `AI_PROVIDER_ROUTE_LLM`. + +Keep `QINIU_MAAS_API_KEY` in the backend environment only. It must not use a +`VITE_` prefix and must not be written to application logs or persisted data. + For Qiniu RTI, the backend maps the selected UniSpeaking teacher or examiner voice to a Qiniu profile voice, validates the model, role, resolved voice, and transport against Profiles, creates an RTI Session, and submits the browser SDP