feat(stt): route OpenAI Whisper provider to any OpenAI-compatible endpoint (#76)

This commit is contained in:
matevip 2026-05-09 13:57:52 +08:00
parent 77456d26f8
commit 88fb28a22a
9 changed files with 411 additions and 52 deletions

View File

@ -0,0 +1,46 @@
package vip.mate.stt;
/**
* Issue #76: protocol-family abstraction for STT.
*
* <p>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.
*
* <p>Two transports cover ~99% of the market today:
* <ul>
* <li>OpenAI Whisper compatible HTTP multipart (this transport)</li>
* <li>DashScope realtime WebSocket (kept inline in
* {@code DashScopeSttProvider} for now its own transport class
* can be carved out the same way when a second WebSocket-based
* vendor lands)</li>
* </ul>
*
* <p>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);
}

View File

@ -0,0 +1,19 @@
package vip.mate.stt;
/**
* Issue #76: resolved endpoint config passed to an {@link SttTransport}.
*
* <p>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) {
}

View File

@ -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
* <p>
* 复用模型管理中的 OpenAI API Key
* OpenAI Whisper / OpenAI-compatible STT provider thin wrapper.
*
* <p>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:
*
* <ul>
* <li>Wire protocol lives in {@link OpenAiCompatibleSttTransport}.</li>
* <li>Credential row is selected by {@code SystemSettingsDTO.sttOpenAiCompatProviderId}
* (defaults to {@code "openai"} for backwards compatibility).</li>
* <li>Model is selected by {@code SystemSettingsDTO.sttOpenAiCompatModel}
* (defaults to {@code "whisper-1"}).</li>
* </ul>
*
* <p>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;
}
}

View File

@ -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.
*
* <p>Wire shape:
* <ul>
* <li>{@code POST {baseUrl}/v1/audio/transcriptions}
* (or {@code {baseUrl}/audio/transcriptions} when baseUrl already
* carries a {@code /vN} suffix)</li>
* <li>multipart/form-data with {@code model} field + {@code file} field
* carrying the audio bytes named after the detected mime type
* (Hutool infers the multipart Content-Type from the extension
* {@link AudioMimeTypes#resolveFileName} is what makes that work).</li>
* <li>Optional {@code Authorization: Bearer <api_key>} when the caller
* supplies one. Self-hosted FunASR commonly skips auth entirely.</li>
* </ul>
*
* <p>Response: {@code { "text": "..." }} the only field we read.
*
* <p>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";
}
}

View File

@ -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;

View File

@ -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) {

View File

@ -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.',

View File

@ -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 RealtimeWebSocket 流式),中文识别效果优秀,亚秒级延迟。',
// Issue #76
sttOpenAiCompatProviderId: '从模型管理选一个 OpenAI 兼容 provider 行作为凭证baseUrl + API Key来源。除官方 OpenAI 外FunASR 私有部署 / 硅基流动 / Groq / Together / 火山 / 七牛等都可以用——在模型管理新增自定义 provider 后即可在此选用。',
sttOpenAiCompatModel: '发送给端点的模型名multipart "model" 字段。OpenAI 默认 whisper-1FunASR 通常是 paraformer-large其他厂商按其文档填写。',
sttOpenAiCompatNote: '提示:要接私有 ASR 服务,先去模型管理 → 新增自定义 provider → 协议选 "OpenAI 兼容" → 填 Base URL + 可选 API Key然后回到这里选它。',
// 音乐生成
musicEnabled: '开启后 Agent 可通过 music_generate 工具生成音乐。Google Lyria 复用 Google Key。',
musicProvider: '选择首选音乐提供商auto 模式优先使用 Google Lyria。',

View File

@ -50,10 +50,52 @@
<template v-if="settings.sttEnabled">
<div class="provider-section">
<div class="provider-header">
<span class="provider-name">OpenAI Whisper</span>
<span class="provider-name">OpenAI / OpenAI-compatible (Whisper)</span>
<span class="provider-tag">{{ t('settings.sttProviderTags.reuseLlmKey') }}</span>
</div>
<div class="settings-card"><div class="setting-item"><div class="setting-info"><div class="setting-hint">{{ t('settings.hints.openaiSttInfo') }}</div></div></div></div>
<!-- Issue #76: let users point this provider at any OpenAI-compat
endpoint (FunASR / SiliconFlow / Groq / Together / Volcano /
Qiniu / self-hosted ...) by selecting the credential row + model. -->
<div class="settings-card">
<div class="setting-item">
<div class="setting-info">
<div class="setting-label">{{ t('settings.fields.sttOpenAiCompatProviderId') }}</div>
<div class="setting-hint">{{ t('settings.hints.sttOpenAiCompatProviderId') }}</div>
</div>
<div class="setting-control">
<select
v-model="settings.sttOpenAiCompatProviderId"
class="form-input"
:disabled="!settings.sttEnabled"
>
<option
v-for="p in openAiCompatProviders"
:key="p.id"
:value="p.id"
>{{ p.name }}{{ p.id === 'openai' ? '' : ` (${p.id})` }}</option>
</select>
</div>
</div>
<div class="setting-item">
<div class="setting-info">
<div class="setting-label">{{ t('settings.fields.sttOpenAiCompatModel') }}</div>
<div class="setting-hint">{{ t('settings.hints.sttOpenAiCompatModel') }}</div>
</div>
<div class="setting-control">
<input
v-model="settings.sttOpenAiCompatModel"
class="form-input"
:disabled="!settings.sttEnabled"
placeholder="whisper-1"
/>
</div>
</div>
<div class="setting-item">
<div class="setting-info">
<div class="setting-hint">{{ t('settings.hints.sttOpenAiCompatNote') }}</div>
</div>
</div>
</div>
</div>
<div class="provider-section">
<div class="provider-header">
@ -73,15 +115,69 @@
</template>
<script setup lang="ts">
import { onMounted, reactive, ref } from 'vue'
import { computed, onMounted, reactive, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { settingsApi } from '@/api'
import { modelApi, settingsApi } from '@/api'
const { t } = useI18n()
const savedTip = ref('')
const settings = reactive({ sttEnabled: false, sttProvider: 'auto', sttFallbackEnabled: true })
const settings = reactive({
sttEnabled: false,
sttProvider: 'auto',
sttFallbackEnabled: true,
// Issue #76: route OpenAI Whisper provider to any OpenAI-compat credential row.
sttOpenAiCompatProviderId: 'openai',
sttOpenAiCompatModel: 'whisper-1',
})
onMounted(() => loadSettings())
interface ProviderRow {
id: string
name: string
chatModel?: string
protocol?: string
}
const providers = ref<ProviderRow[]>([])
/**
* Issue #76: surface every OpenAI-compatible credential row as a STT
* endpoint candidate. This includes the bundled OpenAI provider, Kimi /
* DeepSeek / Together / SiliconFlow / Groq / Volcano / Qiniu (all share
* `OpenAIChatModel`), and any user-added custom provider that picks the
* OpenAI-compatible protocol. The user's self-hosted FunASR fits the
* latter they add a custom provider with baseUrl http://internal/v1
* and select it here.
*/
const openAiCompatProviders = computed<ProviderRow[]>(() => {
const list = providers.value.filter(p =>
(p.chatModel === 'OpenAIChatModel') || (p.protocol === 'openai-compatible')
)
if (list.some(p => p.id === settings.sttOpenAiCompatProviderId)) return list
// Always render the currently-saved id even if its row was disabled / removed,
// so the user can see what's persisted instead of silent fallback to "openai".
return [
...list,
{ id: settings.sttOpenAiCompatProviderId, name: settings.sttOpenAiCompatProviderId },
]
})
onMounted(async () => {
await Promise.all([loadProviders(), loadSettings()])
})
async function loadProviders() {
try {
const res: any = await modelApi.listProviders()
providers.value = (res?.data || []).map((p: any) => ({
id: p.id,
name: p.name,
chatModel: p.chatModel,
protocol: p.protocol,
}))
} catch {
providers.value = []
}
}
async function loadSettings() {
const res: any = await settingsApi.get()
@ -89,10 +185,18 @@ async function loadSettings() {
settings.sttEnabled = d.sttEnabled ?? false
settings.sttProvider = d.sttProvider ?? 'auto'
settings.sttFallbackEnabled = d.sttFallbackEnabled ?? true
settings.sttOpenAiCompatProviderId = d.sttOpenAiCompatProviderId ?? 'openai'
settings.sttOpenAiCompatModel = d.sttOpenAiCompatModel ?? 'whisper-1'
}
async function onSaveSettings() {
await settingsApi.update({ sttEnabled: settings.sttEnabled, sttProvider: settings.sttProvider, sttFallbackEnabled: settings.sttFallbackEnabled })
await settingsApi.update({
sttEnabled: settings.sttEnabled,
sttProvider: settings.sttProvider,
sttFallbackEnabled: settings.sttFallbackEnabled,
sttOpenAiCompatProviderId: settings.sttOpenAiCompatProviderId,
sttOpenAiCompatModel: settings.sttOpenAiCompatModel,
})
await loadSettings()
savedTip.value = t('settings.messages.saveSuccess')
setTimeout(() => { savedTip.value = '' }, 2500)