From 4c9394a86f76ad43c673c16af3ce104e9da80749 Mon Sep 17 00:00:00 2001 From: matevip Date: Wed, 5 Aug 2026 04:26:30 -0400 Subject: [PATCH] fix(stt): surface endpoint, status and body diagnostics on STT failures (#580) --- .../vip/mate/stt/SttResponseDiagnostics.java | 54 +++++++++++++++++ .../stt/provider/DashScopeSttProvider.java | 24 +++++++- .../OpenAiCompatibleSttTransport.java | 50 +++++++++++++++- .../mate/stt/SttResponseDiagnosticsTest.java | 58 +++++++++++++++++++ .../OpenAiCompatibleSttTransportTest.java | 19 ++++++ 5 files changed, 200 insertions(+), 5 deletions(-) create mode 100644 mateclaw-server/src/main/java/vip/mate/stt/SttResponseDiagnostics.java create mode 100644 mateclaw-server/src/test/java/vip/mate/stt/SttResponseDiagnosticsTest.java diff --git a/mateclaw-server/src/main/java/vip/mate/stt/SttResponseDiagnostics.java b/mateclaw-server/src/main/java/vip/mate/stt/SttResponseDiagnostics.java new file mode 100644 index 00000000..72cc05ec --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/stt/SttResponseDiagnostics.java @@ -0,0 +1,54 @@ +package vip.mate.stt; + +/** + * Shared sanity checks for STT HTTP response bodies. + * + *

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) + "…"; + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/stt/provider/DashScopeSttProvider.java b/mateclaw-server/src/main/java/vip/mate/stt/provider/DashScopeSttProvider.java index 448895d4..68985c54 100644 --- a/mateclaw-server/src/main/java/vip/mate/stt/provider/DashScopeSttProvider.java +++ b/mateclaw-server/src/main/java/vip/mate/stt/provider/DashScopeSttProvider.java @@ -11,6 +11,7 @@ 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.SttResponseDiagnostics; import vip.mate.stt.SttResult; import vip.mate.stt.WavPcmExtractor; import vip.mate.system.model.SystemSettingsDTO; @@ -152,14 +153,33 @@ public class DashScopeSttProvider implements SttProvider { .timeout(HTTP_TIMEOUT_MS) .execute(); + String responseBody = response.body(); 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); return SttResult.failure("DashScope STT 失败: HTTP " + response.getStatus() + (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 200,Content-Type: " + + contentType + ")—— 通常是本机代理或网关拦截了请求,请检查代理/防火墙设置。响应片段: " + + SttResponseDiagnostics.snippet(responseBody)); + } + + String text = parseTranscript(responseBody); log.info("[DashScope STT] Transcribed {} chars (model={}, format={}, audioBytes={})", text.length(), model, format, audio.length); return SttResult.success(text); 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 index 6e28414b..353cad5a 100644 --- a/mateclaw-server/src/main/java/vip/mate/stt/transport/OpenAiCompatibleSttTransport.java +++ b/mateclaw-server/src/main/java/vip/mate/stt/transport/OpenAiCompatibleSttTransport.java @@ -9,6 +9,7 @@ import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Component; import vip.mate.stt.AudioMimeTypes; import vip.mate.stt.SttRequest; +import vip.mate.stt.SttResponseDiagnostics; import vip.mate.stt.SttResult; import vip.mate.stt.SttTransport; import vip.mate.stt.SttTransportConfig; @@ -80,21 +81,64 @@ public class OpenAiCompatibleSttTransport implements SttTransport { } HttpResponse response = http.execute(); + String responseBody = response.body(); 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 200,Content-Type: " + + contentType + ",端点: " + url + + ")—— 请确认 base URL 指向 OpenAI 兼容的语音转写服务,且请求未被代理或网关拦截。响应片段: " + + SttResponseDiagnostics.snippet(responseBody)); + } + JsonNode result = objectMapper.readTree(responseBody); 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()); + String error = extractErrorMessage(responseBody); + 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) { log.error("[OpenAI-compat STT] Error: {}", e.getMessage(), e); 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} * version segment (lmstudio-style), append only {@code /audio/transcriptions}. diff --git a/mateclaw-server/src/test/java/vip/mate/stt/SttResponseDiagnosticsTest.java b/mateclaw-server/src/test/java/vip/mate/stt/SttResponseDiagnosticsTest.java new file mode 100644 index 00000000..717065a6 --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/stt/SttResponseDiagnosticsTest.java @@ -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("blocked")); + assertFalse(SttResponseDiagnostics.looksLikeJson("")); + 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(" x ", + SttResponseDiagnostics.snippet("\n \n x \n")); + + 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(" ")); + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/stt/transport/OpenAiCompatibleSttTransportTest.java b/mateclaw-server/src/test/java/vip/mate/stt/transport/OpenAiCompatibleSttTransportTest.java index 688c2cfb..4b729582 100644 --- a/mateclaw-server/src/test/java/vip/mate/stt/transport/OpenAiCompatibleSttTransportTest.java +++ b/mateclaw-server/src/test/java/vip/mate/stt/transport/OpenAiCompatibleSttTransportTest.java @@ -1,5 +1,6 @@ package vip.mate.stt.transport; +import com.fasterxml.jackson.databind.ObjectMapper; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; @@ -59,4 +60,22 @@ class OpenAiCompatibleSttTransportTest { assertEquals("openai_compatible_audio", 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("gateway error")); + assertEquals("", t.extractErrorMessage("{}")); + assertEquals("", t.extractErrorMessage(null)); + assertEquals("", t.extractErrorMessage(" ")); + } }