diff --git a/mateclaw-server/src/main/java/vip/mate/stt/SttTransport.java b/mateclaw-server/src/main/java/vip/mate/stt/SttTransport.java new file mode 100644 index 00000000..01d5bc1e --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/stt/SttTransport.java @@ -0,0 +1,46 @@ +package vip.mate.stt; + +/** + * Issue #76: protocol-family abstraction for STT. + * + *
The original {@link SttProvider} bundled "which vendor is this" with + * "how does its wire protocol work", forcing every new vendor to ship a + * dedicated Java class even when the wire protocol is identical to an + * existing one. {@code SttTransport} is the protocol-only half: it knows how + * to send a request and parse a response, but doesn't care whether the + * endpoint is OpenAI cloud, FunASR self-hosted, SiliconFlow, Groq, or + * Together — anything that speaks the same protocol can plug in. + * + *
Two transports cover ~99% of the market today: + *
Identity (display name, baseUrl defaults, language bias, ...) is + * declared by a future {@code SttProviderProfile} layer (Phase 2 of the + * refactor). Phase 1 keeps {@link SttProvider} as the public SPI but + * delegates the wire work to a transport so swapping the credential row + * doesn't require changing the provider class. + */ +public interface SttTransport { + + /** + * Stable id of the protocol family this transport speaks. Profiles + * pick a transport by matching against this — e.g. + * {@code "openai_compatible_audio"} for any OpenAI Whisper-shaped + * endpoint. + */ + String apiMode(); + + /** + * Run a transcription against the resolved endpoint. Returns a typed + * success/failure result; transport implementations must NOT throw — + * caller relies on the failure path to keep the {@link SttProvider} + * fallback chain alive. + */ + SttResult transcribe(SttRequest request, SttTransportConfig config); +} diff --git a/mateclaw-server/src/main/java/vip/mate/stt/SttTransportConfig.java b/mateclaw-server/src/main/java/vip/mate/stt/SttTransportConfig.java new file mode 100644 index 00000000..7e276764 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/stt/SttTransportConfig.java @@ -0,0 +1,19 @@ +package vip.mate.stt; + +/** + * Issue #76: resolved endpoint config passed to an {@link SttTransport}. + * + *
Decoupling the transport from {@code ModelProviderService} lookups makes + * tests trivial (no Spring context) and lets the same transport serve any + * credential row — OpenAI cloud, FunASR self-hosted, SiliconFlow, Groq, etc. + * + * @param baseUrl fully-qualified provider base URL ({@code https://api.openai.com} + * or {@code http://10.0.0.5:9999/v1}). Trailing slash optional; + * transports normalize it. + * @param apiKey bearer token. May be blank when the provider doesn't require + * authentication (some self-hosted FunASR deployments). + * @param model the model id sent in the multipart "model" field + * (whisper-1 / paraformer-large / FunAudioLLM-Whisper / ...). + */ +public record SttTransportConfig(String baseUrl, String apiKey, String model) { +} diff --git a/mateclaw-server/src/main/java/vip/mate/stt/provider/OpenAiSttProvider.java b/mateclaw-server/src/main/java/vip/mate/stt/provider/OpenAiSttProvider.java index 1ac7a75e..3257b56b 100644 --- a/mateclaw-server/src/main/java/vip/mate/stt/provider/OpenAiSttProvider.java +++ b/mateclaw-server/src/main/java/vip/mate/stt/provider/OpenAiSttProvider.java @@ -1,23 +1,40 @@ package vip.mate.stt.provider; -import cn.hutool.http.HttpRequest; -import cn.hutool.http.HttpResponse; -import com.fasterxml.jackson.databind.JsonNode; -import com.fasterxml.jackson.databind.ObjectMapper; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Component; +import org.springframework.util.StringUtils; +import vip.mate.exception.MateClawException; +import vip.mate.llm.model.ModelProviderEntity; import vip.mate.llm.service.ModelProviderService; -import vip.mate.stt.AudioMimeTypes; import vip.mate.stt.SttProvider; import vip.mate.stt.SttRequest; import vip.mate.stt.SttResult; +import vip.mate.stt.SttTransportConfig; +import vip.mate.stt.transport.OpenAiCompatibleSttTransport; import vip.mate.system.model.SystemSettingsDTO; /** - * OpenAI STT Provider — Whisper / gpt-4o-mini-transcribe - *
- * 复用模型管理中的 OpenAI API Key。 + * OpenAI Whisper / OpenAI-compatible STT provider — thin wrapper. + * + *
Issue #76: this used to bake the {@code id="openai"} credential row + the + * {@code https://api.openai.com} base URL + Whisper-1 directly into the + * transport call, so the only way to point STT at FunASR / SiliconFlow / Groq + * was to hand-edit the OpenAI provider row's baseUrl (lossy + side-effects on + * chat). After this refactor: + * + *
The provider id stays {@code "openai"} because settings UI / fallback + * registry / per-language ordering all key off it. Phase 2 of the refactor + * will replace this single provider with a profile-driven registry; until + * then, swapping the credential row is the path forward for new vendors. */ @Slf4j @Component @@ -25,12 +42,13 @@ import vip.mate.system.model.SystemSettingsDTO; public class OpenAiSttProvider implements SttProvider { private final ModelProviderService modelProviderService; - private final ObjectMapper objectMapper; + private final OpenAiCompatibleSttTransport transport; - private static final String DEFAULT_MODEL = "whisper-1"; + private static final String LEGACY_DEFAULT_PROVIDER_ID = "openai"; + private static final String LEGACY_DEFAULT_MODEL = "whisper-1"; @Override public String id() { return "openai"; } - @Override public String label() { return "OpenAI Whisper"; } + @Override public String label() { return "OpenAI / OpenAI-compatible (Whisper)"; } @Override public boolean requiresCredential() { return true; } @Override public int autoDetectOrder() { return 100; } @@ -56,7 +74,8 @@ public class OpenAiSttProvider implements SttProvider { @Override public boolean isAvailable(SystemSettingsDTO config) { try { - return modelProviderService.isProviderConfigured("openai"); + String providerId = resolveProviderId(config); + return modelProviderService.isProviderConfigured(providerId); } catch (Exception e) { log.warn("[OpenAI STT] availability check failed: {}", e.getMessage()); return false; @@ -65,40 +84,39 @@ public class OpenAiSttProvider implements SttProvider { @Override public SttResult transcribe(SttRequest request, SystemSettingsDTO config) { + String providerId = resolveProviderId(config); + ModelProviderEntity provider; try { - String apiKey = modelProviderService.getProviderConfig("openai").getApiKey(); - String baseUrl = modelProviderService.getProviderConfig("openai").getBaseUrl(); - if (apiKey == null) return SttResult.failure("OpenAI API Key 未配置"); - - String url = (baseUrl != null ? baseUrl : "https://api.openai.com") + "/v1/audio/transcriptions"; - String model = request.getModel() != null ? request.getModel() : DEFAULT_MODEL; - // AudioMimeTypes ensures the filename extension matches the - // actual bytes (audio.wav, audio.mp3, etc.), which Hutool then - // uses to infer the multipart Content-Type. Don't pass - // contentType to .form() explicitly — Hutool has no - // form(String,byte[],String,String) overload, and the wrong - // dispatch crashes with ClassCastException on byte[] → Object[]. - String fileName = AudioMimeTypes.resolveFileName(request.getFileName(), request.getContentType()); - - HttpResponse response = HttpRequest.post(url) - .header("Authorization", "Bearer " + apiKey) - .form("model", model) - .form("file", request.getAudioData(), fileName) - .timeout(60_000) - .execute(); - - if (response.getStatus() == 200) { - JsonNode result = objectMapper.readTree(response.body()); - String text = result.path("text").asText(""); - log.info("[OpenAI STT] Transcribed {} chars (model={})", text.length(), model); - return SttResult.success(text); - } else { - log.warn("[OpenAI STT] Failed: HTTP {} - {}", response.getStatus(), response.body()); - return SttResult.failure("OpenAI STT 失败: HTTP " + response.getStatus()); - } - } catch (Exception e) { - log.error("[OpenAI STT] Error: {}", e.getMessage(), e); - return SttResult.failure("OpenAI STT 异常: " + e.getMessage()); + provider = modelProviderService.getProviderConfig(providerId); + } catch (MateClawException e) { + return SttResult.failure("STT 凭证 provider 未找到: " + providerId); } + + String apiKey = provider.getApiKey(); + String baseUrl = StringUtils.hasText(provider.getBaseUrl()) + ? provider.getBaseUrl() + : "https://api.openai.com"; + + // Allow blank apiKey for self-hosted / no-auth setups (FunASR is the + // typical case). The transport will only attach the Authorization + // header when apiKey is present. + boolean requiresKey = Boolean.TRUE.equals(provider.getRequireApiKey()); + if (requiresKey && (apiKey == null || apiKey.isBlank())) { + return SttResult.failure("STT 凭证 provider 未配置 API Key: " + providerId); + } + + String model = resolveModel(config); + SttTransportConfig transportConfig = new SttTransportConfig(baseUrl, apiKey, model); + return transport.transcribe(request, transportConfig); + } + + private String resolveProviderId(SystemSettingsDTO config) { + String configured = config != null ? config.getSttOpenAiCompatProviderId() : null; + return StringUtils.hasText(configured) ? configured.trim() : LEGACY_DEFAULT_PROVIDER_ID; + } + + private String resolveModel(SystemSettingsDTO config) { + String configured = config != null ? config.getSttOpenAiCompatModel() : null; + return StringUtils.hasText(configured) ? configured.trim() : LEGACY_DEFAULT_MODEL; } } diff --git a/mateclaw-server/src/main/java/vip/mate/stt/transport/OpenAiCompatibleSttTransport.java b/mateclaw-server/src/main/java/vip/mate/stt/transport/OpenAiCompatibleSttTransport.java new file mode 100644 index 00000000..6e28414b --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/stt/transport/OpenAiCompatibleSttTransport.java @@ -0,0 +1,128 @@ +package vip.mate.stt.transport; + +import cn.hutool.http.HttpRequest; +import cn.hutool.http.HttpResponse; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; +import vip.mate.stt.AudioMimeTypes; +import vip.mate.stt.SttRequest; +import vip.mate.stt.SttResult; +import vip.mate.stt.SttTransport; +import vip.mate.stt.SttTransportConfig; + +/** + * Issue #76: protocol family transport for the OpenAI Whisper-shaped HTTP + * audio endpoint. Identical request format covers OpenAI itself, FunASR with + * the openai-compat shim, SiliconFlow, Groq Whisper, Together, Volcano, + * and roughly every other paid + self-hosted ASR vendor available today. + * + *
Wire shape: + *
Response: {@code { "text": "..." }} — the only field we read. + * + *
The transport intentionally does NOT touch {@code ModelProviderService}
+ * or {@code SystemSettingsDTO}: the caller resolves credentials and hands
+ * them in via {@link SttTransportConfig}. This keeps the transport reusable
+ * across any number of credential rows and trivially unit-testable.
+ */
+@Slf4j
+@Component
+@RequiredArgsConstructor
+public class OpenAiCompatibleSttTransport implements SttTransport {
+
+ public static final String API_MODE = "openai_compatible_audio";
+
+ private final ObjectMapper objectMapper;
+
+ @Override
+ public String apiMode() {
+ return API_MODE;
+ }
+
+ @Override
+ public SttResult transcribe(SttRequest request, SttTransportConfig config) {
+ try {
+ String baseUrl = normalizeBaseUrl(config.baseUrl());
+ if (baseUrl == null) {
+ return SttResult.failure("STT 端点 base URL 未配置");
+ }
+ String url = baseUrl + resolveAudioPath(baseUrl);
+ String model = effectiveModel(request, config);
+ // AudioMimeTypes ensures the filename extension matches the
+ // actual bytes (audio.wav, audio.mp3, etc.), which Hutool then
+ // uses to infer the multipart Content-Type. Don't pass
+ // contentType to .form() explicitly — Hutool has no
+ // form(String,byte[],String,String) overload, and the wrong
+ // dispatch crashes with ClassCastException on byte[] → Object[].
+ String fileName = AudioMimeTypes.resolveFileName(request.getFileName(), request.getContentType());
+
+ HttpRequest http = HttpRequest.post(url)
+ .form("model", model)
+ .form("file", request.getAudioData(), fileName)
+ .timeout(60_000);
+ String apiKey = config.apiKey();
+ if (apiKey != null && !apiKey.isBlank()) {
+ http.header("Authorization", "Bearer " + apiKey.trim());
+ }
+
+ HttpResponse response = http.execute();
+ if (response.getStatus() == 200) {
+ JsonNode result = objectMapper.readTree(response.body());
+ String text = result.path("text").asText("");
+ log.info("[OpenAI-compat STT] Transcribed {} chars (model={}, baseUrl={})",
+ text.length(), model, baseUrl);
+ return SttResult.success(text);
+ }
+ log.warn("[OpenAI-compat STT] Failed: HTTP {} - {}", response.getStatus(), response.body());
+ return SttResult.failure("STT 失败: HTTP " + response.getStatus());
+ } catch (Exception e) {
+ log.error("[OpenAI-compat STT] Error: {}", e.getMessage(), e);
+ return SttResult.failure("STT 异常: " + e.getMessage());
+ }
+ }
+
+ /**
+ * Pick the audio path to append. If baseUrl already ends in a {@code /vN}
+ * version segment (lmstudio-style), append only {@code /audio/transcriptions}.
+ * Otherwise append {@code /v1/audio/transcriptions}. Mirrors the resolver
+ * pattern used by the chat-models probe so user-set baseUrls behave
+ * consistently across endpoints.
+ */
+ static String resolveAudioPath(String baseUrl) {
+ if (baseUrl != null && baseUrl.matches(".*/v\\d{1,2}$")) {
+ return "/audio/transcriptions";
+ }
+ return "/v1/audio/transcriptions";
+ }
+
+ static String normalizeBaseUrl(String raw) {
+ if (raw == null) return null;
+ String trimmed = raw.trim();
+ if (trimmed.isEmpty()) return null;
+ return trimmed.endsWith("/") ? trimmed.substring(0, trimmed.length() - 1) : trimmed;
+ }
+
+ private static String effectiveModel(SttRequest request, SttTransportConfig config) {
+ if (request.getModel() != null && !request.getModel().isBlank()) {
+ return request.getModel();
+ }
+ if (config.model() != null && !config.model().isBlank()) {
+ return config.model();
+ }
+ return "whisper-1";
+ }
+}
diff --git a/mateclaw-server/src/main/java/vip/mate/system/model/SystemSettingsDTO.java b/mateclaw-server/src/main/java/vip/mate/system/model/SystemSettingsDTO.java
index 25b21b8e..ace13763 100644
--- a/mateclaw-server/src/main/java/vip/mate/system/model/SystemSettingsDTO.java
+++ b/mateclaw-server/src/main/java/vip/mate/system/model/SystemSettingsDTO.java
@@ -108,6 +108,20 @@ public class SystemSettingsDTO {
/** 首选 STT provider: auto / openai / dashscope */
private String sttProvider;
private Boolean sttFallbackEnabled;
+ /**
+ * Issue #76: which {@code mate_model_provider} row should the OpenAI STT
+ * provider read its baseUrl + apiKey from. Defaults to "openai" so existing
+ * deployments keep working; swap to a custom OpenAI-compatible provider row
+ * (FunASR / SiliconFlow / Groq / Together / Volcano / etc.) to point STT
+ * at any compatible endpoint without a code change.
+ */
+ private String sttOpenAiCompatProviderId;
+ /**
+ * Issue #76: model id sent in the multipart "model" field. Defaults to
+ * whisper-1; override with paraformer-large / FunAudioLLM-Whisper / etc.
+ * when the configured provider exposes a different identifier.
+ */
+ private String sttOpenAiCompatModel;
// ===== 音乐生成配置 =====
private Boolean musicEnabled;
diff --git a/mateclaw-server/src/main/java/vip/mate/system/service/SystemSettingService.java b/mateclaw-server/src/main/java/vip/mate/system/service/SystemSettingService.java
index 17bcdae0..e67cb3d8 100644
--- a/mateclaw-server/src/main/java/vip/mate/system/service/SystemSettingService.java
+++ b/mateclaw-server/src/main/java/vip/mate/system/service/SystemSettingService.java
@@ -49,6 +49,9 @@ public class SystemSettingService {
private static final String STT_ENABLED_KEY = "sttEnabled";
private static final String STT_PROVIDER_KEY = "sttProvider";
private static final String STT_FALLBACK_ENABLED_KEY = "sttFallbackEnabled";
+ // Issue #76: let the OpenAI STT provider point at any OpenAI-compat endpoint.
+ private static final String STT_OPENAI_COMPAT_PROVIDER_ID_KEY = "sttOpenAiCompatProviderId";
+ private static final String STT_OPENAI_COMPAT_MODEL_KEY = "sttOpenAiCompatModel";
// 音乐生成配置 keys
private static final String MUSIC_ENABLED_KEY = "musicEnabled";
@@ -134,6 +137,10 @@ public class SystemSettingService {
dto.setSttEnabled(Boolean.parseBoolean(getValue(STT_ENABLED_KEY, "false")));
dto.setSttProvider(getValue(STT_PROVIDER_KEY, "auto"));
dto.setSttFallbackEnabled(Boolean.parseBoolean(getValue(STT_FALLBACK_ENABLED_KEY, "true")));
+ // Issue #76: default to "openai" so upgrades behave identically to the
+ // old hard-coded path; users can swap to any OpenAI-compat provider row.
+ dto.setSttOpenAiCompatProviderId(getValue(STT_OPENAI_COMPAT_PROVIDER_ID_KEY, "openai"));
+ dto.setSttOpenAiCompatModel(getValue(STT_OPENAI_COMPAT_MODEL_KEY, "whisper-1"));
// 音乐生成配置
dto.setMusicEnabled(Boolean.parseBoolean(getValue(MUSIC_ENABLED_KEY, "false")));
@@ -295,6 +302,15 @@ public class SystemSettingService {
if (dto.getSttFallbackEnabled() != null) {
saveValue(STT_FALLBACK_ENABLED_KEY, String.valueOf(dto.getSttFallbackEnabled()), "STT Provider 级 Fallback");
}
+ // Issue #76: persist the OpenAI-compatible STT routing target.
+ if (dto.getSttOpenAiCompatProviderId() != null) {
+ saveValue(STT_OPENAI_COMPAT_PROVIDER_ID_KEY, dto.getSttOpenAiCompatProviderId(),
+ "OpenAI-compat STT 凭证来源 provider id");
+ }
+ if (dto.getSttOpenAiCompatModel() != null) {
+ saveValue(STT_OPENAI_COMPAT_MODEL_KEY, dto.getSttOpenAiCompatModel(),
+ "OpenAI-compat STT 模型名");
+ }
// 音乐生成配置
if (dto.getMusicEnabled() != null) {
diff --git a/mateclaw-ui/src/i18n/locales/en-US.ts b/mateclaw-ui/src/i18n/locales/en-US.ts
index ffb59e5d..53c72eb5 100644
--- a/mateclaw-ui/src/i18n/locales/en-US.ts
+++ b/mateclaw-ui/src/i18n/locales/en-US.ts
@@ -757,6 +757,9 @@ export default {
sttEnabled: 'Enable Speech Recognition',
sttProvider: 'Preferred STT Provider',
sttFallbackEnabled: 'Provider Fallback',
+ // Issue #76: route the OpenAI-compat STT endpoint
+ sttOpenAiCompatProviderId: 'OpenAI-compatible Credential',
+ sttOpenAiCompatModel: 'OpenAI-compatible Model',
musicEnabled: 'Enable Music Generation',
musicProvider: 'Preferred Music Provider',
musicFallbackEnabled: 'Provider Fallback',
@@ -804,6 +807,10 @@ export default {
sttFallbackEnabled: 'Automatically try other configured providers if the preferred one fails.',
openaiSttInfo: 'Reuses OpenAI API Key from Model Management. Whisper model, supports multilingual auto-detection.',
dashscopeSttInfo: 'Reuses DashScope API Key from Model Management. Paraformer Realtime over WebSocket — strong Chinese recognition, sub-second latency.',
+ // Issue #76
+ sttOpenAiCompatProviderId: 'Pick any OpenAI-compatible provider from Model Management as the credential source (baseUrl + API key). Beyond OpenAI itself this covers self-hosted FunASR, SiliconFlow, Groq, Together, Volcano, Qiniu, and any custom provider you add with the OpenAI-compatible protocol.',
+ sttOpenAiCompatModel: 'Model id sent in the multipart "model" field. Defaults to whisper-1; use paraformer-large for FunASR, or whatever id your vendor documents.',
+ sttOpenAiCompatNote: 'Tip: to plug in a private ASR service, head to Model Management → Add Custom Provider → pick the "OpenAI Compatible" protocol → fill in Base URL (and an optional API key), then come back and select it here.',
musicEnabled: 'Enable to let Agent use the music generation tool. Google Lyria reuses Google Key.',
musicProvider: 'Select preferred music provider. Auto mode prioritizes Google Lyria.',
musicFallbackEnabled: 'Automatically try other configured providers if the preferred one fails.',
diff --git a/mateclaw-ui/src/i18n/locales/zh-CN.ts b/mateclaw-ui/src/i18n/locales/zh-CN.ts
index 65404185..0ecb43a5 100644
--- a/mateclaw-ui/src/i18n/locales/zh-CN.ts
+++ b/mateclaw-ui/src/i18n/locales/zh-CN.ts
@@ -644,6 +644,9 @@ export default {
sttEnabled: '启用语音识别',
sttProvider: '首选 STT 提供商',
sttFallbackEnabled: '提供商回退',
+ // Issue #76: OpenAI-compat STT 端点路由
+ sttOpenAiCompatProviderId: 'OpenAI 兼容凭证',
+ sttOpenAiCompatModel: 'OpenAI 兼容模型名',
// 音乐生成
musicEnabled: '启用音乐生成',
musicProvider: '首选音乐提供商',
@@ -697,6 +700,10 @@ export default {
sttFallbackEnabled: '首选提供商失败时自动尝试其他已配置的提供商。',
openaiSttInfo: '复用模型管理中的 OpenAI API Key。使用 Whisper 模型,支持多语言自动识别。',
dashscopeSttInfo: '复用模型管理中的 DashScope API Key。使用 Paraformer Realtime(WebSocket 流式),中文识别效果优秀,亚秒级延迟。',
+ // Issue #76
+ sttOpenAiCompatProviderId: '从模型管理选一个 OpenAI 兼容 provider 行作为凭证(baseUrl + API Key)来源。除官方 OpenAI 外,FunASR 私有部署 / 硅基流动 / Groq / Together / 火山 / 七牛等都可以用——在模型管理新增自定义 provider 后即可在此选用。',
+ sttOpenAiCompatModel: '发送给端点的模型名(multipart "model" 字段)。OpenAI 默认 whisper-1;FunASR 通常是 paraformer-large;其他厂商按其文档填写。',
+ sttOpenAiCompatNote: '提示:要接私有 ASR 服务,先去模型管理 → 新增自定义 provider → 协议选 "OpenAI 兼容" → 填 Base URL + 可选 API Key,然后回到这里选它。',
// 音乐生成
musicEnabled: '开启后 Agent 可通过 music_generate 工具生成音乐。Google Lyria 复用 Google Key。',
musicProvider: '选择首选音乐提供商,auto 模式优先使用 Google Lyria。',
diff --git a/mateclaw-ui/src/views/Settings/Stt/index.vue b/mateclaw-ui/src/views/Settings/Stt/index.vue
index 68b21113..39692903 100644
--- a/mateclaw-ui/src/views/Settings/Stt/index.vue
+++ b/mateclaw-ui/src/views/Settings/Stt/index.vue
@@ -50,10 +50,52 @@