Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. 当前实现矩阵

Expand Down
8 changes: 6 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
- 科大讯飞发音评分
Expand All @@ -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` 只能配置在后端环境中。

## 本地启动

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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;
}
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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<String, Object> 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<InputStream> 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;
}
}
}
Original file line number Diff line number Diff line change
@@ -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;
}
}
Original file line number Diff line number Diff line change
@@ -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());
}
}
Loading
Loading