fix(stt): surface endpoint, status and body diagnostics on STT failures (#580)

This commit is contained in:
matevip 2026-08-05 04:26:30 -04:00
parent c31a216cbd
commit 4c9394a86f
5 changed files with 200 additions and 5 deletions

View File

@ -0,0 +1,54 @@
package vip.mate.stt;
/**
* Shared sanity checks for STT HTTP response bodies.
*
* <p>STT endpoints normally answer JSON, but two real-world failure modes
* deliver something else with a 200 status: an intercepting proxy / gateway
* (corporate proxy, captive portal, local traffic tool) substituting an HTML
* page, or a misconfigured base URL that points at a web UI instead of the
* transcription API. Feeding such a body straight into Jackson surfaces a raw
* {@code JsonParseException: Unexpected character ('<'...)} to the end user
* with no provider, endpoint, status, or body context, it is undiagnosable
* (see issue #580 retest reports). Providers use these helpers to detect the
* situation up front and build an actionable error message instead.
*/
public final class SttResponseDiagnostics {
/** Cap for the response-body excerpt embedded in error messages. */
static final int MAX_SNIPPET_CHARS = 200;
private SttResponseDiagnostics() {
}
/**
* Cheap structural check: does the body plausibly parse as JSON?
* Tolerates leading whitespace and a UTF-8 BOM. Intentionally does not
* attempt a full parse the caller parses right after when this passes.
*/
public static boolean looksLikeJson(String body) {
if (body == null) {
return false;
}
String trimmed = body.trim();
if (!trimmed.isEmpty() && trimmed.charAt(0) == '\uFEFF') {
trimmed = trimmed.substring(1).trim();
}
return trimmed.startsWith("{") || trimmed.startsWith("[");
}
/**
* Compact single-line excerpt of a response body for logs and error
* messages: whitespace collapsed, truncated to {@link #MAX_SNIPPET_CHARS}.
*/
public static String snippet(String body) {
if (body == null || body.isBlank()) {
return "(空响应体)";
}
String collapsed = body.trim().replaceAll("\\s+", " ");
if (collapsed.length() <= MAX_SNIPPET_CHARS) {
return collapsed;
}
return collapsed.substring(0, MAX_SNIPPET_CHARS) + "";
}
}

View File

@ -11,6 +11,7 @@ import vip.mate.llm.service.ModelProviderService;
import vip.mate.stt.AudioMimeTypes; import vip.mate.stt.AudioMimeTypes;
import vip.mate.stt.SttProvider; import vip.mate.stt.SttProvider;
import vip.mate.stt.SttRequest; import vip.mate.stt.SttRequest;
import vip.mate.stt.SttResponseDiagnostics;
import vip.mate.stt.SttResult; import vip.mate.stt.SttResult;
import vip.mate.stt.WavPcmExtractor; import vip.mate.stt.WavPcmExtractor;
import vip.mate.system.model.SystemSettingsDTO; import vip.mate.system.model.SystemSettingsDTO;
@ -152,14 +153,33 @@ public class DashScopeSttProvider implements SttProvider {
.timeout(HTTP_TIMEOUT_MS) .timeout(HTTP_TIMEOUT_MS)
.execute(); .execute();
String responseBody = response.body();
if (response.getStatus() != 200) { if (response.getStatus() != 200) {
String error = parseErrorMessage(response.body()); String error = parseErrorMessage(responseBody);
if (error.isEmpty()) {
// Non-JSON error body (HTML gateway page etc.) surface
// an excerpt so the failure stays diagnosable.
error = SttResponseDiagnostics.snippet(responseBody);
}
log.warn("[DashScope STT] HTTP {} — {}", response.getStatus(), error); log.warn("[DashScope STT] HTTP {} — {}", response.getStatus(), error);
return SttResult.failure("DashScope STT 失败: HTTP " + response.getStatus() return SttResult.failure("DashScope STT 失败: HTTP " + response.getStatus()
+ (error.isEmpty() ? "" : "" + error)); + (error.isEmpty() ? "" : "" + error));
} }
String text = parseTranscript(response.body()); if (!SttResponseDiagnostics.looksLikeJson(responseBody)) {
// A 200 with a non-JSON body never comes from DashScope itself
// (the endpoint is hardcoded HTTPS) it means a proxy or
// gateway on the way answered instead. Report that precisely
// rather than letting Jackson throw an opaque parse error.
String contentType = response.header("Content-Type");
log.warn("[DashScope STT] HTTP 200 with non-JSON body (Content-Type: {}) — {}",
contentType, SttResponseDiagnostics.snippet(responseBody));
return SttResult.failure("DashScope 返回了非 JSON 响应HTTP 200Content-Type: "
+ contentType + ")—— 通常是本机代理或网关拦截了请求,请检查代理/防火墙设置。响应片段: "
+ SttResponseDiagnostics.snippet(responseBody));
}
String text = parseTranscript(responseBody);
log.info("[DashScope STT] Transcribed {} chars (model={}, format={}, audioBytes={})", log.info("[DashScope STT] Transcribed {} chars (model={}, format={}, audioBytes={})",
text.length(), model, format, audio.length); text.length(), model, format, audio.length);
return SttResult.success(text); return SttResult.success(text);

View File

@ -9,6 +9,7 @@ import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
import vip.mate.stt.AudioMimeTypes; import vip.mate.stt.AudioMimeTypes;
import vip.mate.stt.SttRequest; import vip.mate.stt.SttRequest;
import vip.mate.stt.SttResponseDiagnostics;
import vip.mate.stt.SttResult; import vip.mate.stt.SttResult;
import vip.mate.stt.SttTransport; import vip.mate.stt.SttTransport;
import vip.mate.stt.SttTransportConfig; import vip.mate.stt.SttTransportConfig;
@ -80,21 +81,64 @@ public class OpenAiCompatibleSttTransport implements SttTransport {
} }
HttpResponse response = http.execute(); HttpResponse response = http.execute();
String responseBody = response.body();
if (response.getStatus() == 200) { if (response.getStatus() == 200) {
JsonNode result = objectMapper.readTree(response.body()); if (!SttResponseDiagnostics.looksLikeJson(responseBody)) {
// A 200 with an HTML/non-JSON body means the URL answered
// with a web page instead of the transcription API
// usually a wrong base URL or an intercepting proxy.
// Report that precisely rather than letting Jackson throw
// an opaque parse error.
String contentType = response.header("Content-Type");
log.warn("[OpenAI-compat STT] HTTP 200 with non-JSON body from {} (Content-Type: {}) — {}",
url, contentType, SttResponseDiagnostics.snippet(responseBody));
return SttResult.failure("STT 端点返回了非 JSON 响应HTTP 200Content-Type: "
+ contentType + ",端点: " + url
+ ")—— 请确认 base URL 指向 OpenAI 兼容的语音转写服务,且请求未被代理或网关拦截。响应片段: "
+ SttResponseDiagnostics.snippet(responseBody));
}
JsonNode result = objectMapper.readTree(responseBody);
String text = result.path("text").asText(""); String text = result.path("text").asText("");
log.info("[OpenAI-compat STT] Transcribed {} chars (model={}, baseUrl={})", log.info("[OpenAI-compat STT] Transcribed {} chars (model={}, baseUrl={})",
text.length(), model, baseUrl); text.length(), model, baseUrl);
return SttResult.success(text); return SttResult.success(text);
} }
log.warn("[OpenAI-compat STT] Failed: HTTP {} - {}", response.getStatus(), response.body()); String error = extractErrorMessage(responseBody);
return SttResult.failure("STT 失败: HTTP " + response.getStatus()); if (error.isEmpty()) {
error = SttResponseDiagnostics.snippet(responseBody);
}
log.warn("[OpenAI-compat STT] Failed: HTTP {} from {} - {}",
response.getStatus(), url, SttResponseDiagnostics.snippet(responseBody));
return SttResult.failure("STT 失败: HTTP " + response.getStatus()
+ "" + error + "(端点: " + url + "");
} catch (Exception e) { } catch (Exception e) {
log.error("[OpenAI-compat STT] Error: {}", e.getMessage(), e); log.error("[OpenAI-compat STT] Error: {}", e.getMessage(), e);
return SttResult.failure("STT 异常: " + e.getMessage()); return SttResult.failure("STT 异常: " + e.getMessage());
} }
} }
/**
* Pull a human-readable message out of an error body. OpenAI-shaped
* services (OpenAI, Groq, SiliconFlow, Ollama, LM Studio) nest it as
* {@code {"error":{"message":...}}}; FastAPI-based self-hosted servers
* use top-level {@code {"detail":...}}; some shims use plain
* {@code {"message":...}}. Returns "" when nothing usable is found
* the caller then falls back to a raw body snippet.
*/
String extractErrorMessage(String body) {
if (body == null || body.isBlank()) return "";
try {
JsonNode root = objectMapper.readTree(body);
String nested = root.path("error").path("message").asText("");
if (!nested.isEmpty()) return nested;
String detail = root.path("detail").asText("");
if (!detail.isEmpty()) return detail;
return root.path("message").asText("");
} catch (Exception e) {
return "";
}
}
/** /**
* Pick the audio path to append. If baseUrl already ends in a {@code /vN} * Pick the audio path to append. If baseUrl already ends in a {@code /vN}
* version segment (lmstudio-style), append only {@code /audio/transcriptions}. * version segment (lmstudio-style), append only {@code /audio/transcriptions}.

View File

@ -0,0 +1,58 @@
package vip.mate.stt;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
/**
* Pins the non-JSON response detection that keeps proxy/gateway HTML pages
* from reaching Jackson as if they were API responses (issue #580 retest:
* users saw a raw {@code JsonParseException: Unexpected character ('<')}
* with no clue which endpoint produced it).
*/
class SttResponseDiagnosticsTest {
@Test
@DisplayName("looksLikeJson accepts objects and arrays, with whitespace and BOM")
void looksLikeJson_acceptsJsonShapes() {
assertTrue(SttResponseDiagnostics.looksLikeJson("{\"text\":\"hi\"}"));
assertTrue(SttResponseDiagnostics.looksLikeJson(" \n {\"a\":1}"));
assertTrue(SttResponseDiagnostics.looksLikeJson("[1,2]"));
assertTrue(SttResponseDiagnostics.looksLikeJson("\uFEFF{\"a\":1}"));
}
@Test
@DisplayName("looksLikeJson rejects HTML, SSE, plain text, empty and null")
void looksLikeJson_rejectsNonJson() {
assertFalse(SttResponseDiagnostics.looksLikeJson("<html><body>blocked</body></html>"));
assertFalse(SttResponseDiagnostics.looksLikeJson("<?xml version=\"1.0\"?><Error/>"));
assertFalse(SttResponseDiagnostics.looksLikeJson("data: {\"choices\":[]}\n\n"));
assertFalse(SttResponseDiagnostics.looksLikeJson("404 page not found"));
assertFalse(SttResponseDiagnostics.looksLikeJson(""));
assertFalse(SttResponseDiagnostics.looksLikeJson(" "));
assertFalse(SttResponseDiagnostics.looksLikeJson(null));
}
@Test
@DisplayName("snippet collapses whitespace and truncates long bodies")
void snippet_collapsesAndTruncates() {
assertEquals("<html> <body> x </body> </html>",
SttResponseDiagnostics.snippet("<html>\n <body>\n x </body>\n</html>"));
String longBody = "a".repeat(500);
String snippet = SttResponseDiagnostics.snippet(longBody);
assertEquals(SttResponseDiagnostics.MAX_SNIPPET_CHARS + 1, snippet.length());
assertTrue(snippet.endsWith(""));
}
@Test
@DisplayName("snippet reports empty bodies explicitly")
void snippet_emptyBody() {
assertEquals("(空响应体)", SttResponseDiagnostics.snippet(null));
assertEquals("(空响应体)", SttResponseDiagnostics.snippet(""));
assertEquals("(空响应体)", SttResponseDiagnostics.snippet(" "));
}
}

View File

@ -1,5 +1,6 @@
package vip.mate.stt.transport; package vip.mate.stt.transport;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
@ -59,4 +60,22 @@ class OpenAiCompatibleSttTransportTest {
assertEquals("openai_compatible_audio", t.apiMode()); assertEquals("openai_compatible_audio", t.apiMode());
assertEquals(OpenAiCompatibleSttTransport.API_MODE, t.apiMode()); assertEquals(OpenAiCompatibleSttTransport.API_MODE, t.apiMode());
} }
@Test
@DisplayName("extractErrorMessage reads OpenAI-nested, FastAPI-detail and plain-message shapes")
void extractErrorMessageShapes() {
OpenAiCompatibleSttTransport t = new OpenAiCompatibleSttTransport(new ObjectMapper());
// OpenAI / Groq / Ollama / LM Studio shape.
assertEquals("model 'whisper-large-v2' not found",
t.extractErrorMessage("{\"error\":{\"message\":\"model 'whisper-large-v2' not found\",\"type\":\"not_found_error\"}}"));
// FastAPI-based self-hosted servers.
assertEquals("Not Found", t.extractErrorMessage("{\"detail\":\"Not Found\"}"));
// Plain message shims.
assertEquals("boom", t.extractErrorMessage("{\"message\":\"boom\"}"));
// Non-JSON / empty "" so the caller falls back to a body snippet.
assertEquals("", t.extractErrorMessage("<html>gateway error</html>"));
assertEquals("", t.extractErrorMessage("{}"));
assertEquals("", t.extractErrorMessage(null));
assertEquals("", t.extractErrorMessage(" "));
}
} }