mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-13 19:23:42 +08:00
fix(stt): surface endpoint, status and body diagnostics on STT failures (#580)
This commit is contained in:
parent
c31a216cbd
commit
4c9394a86f
@ -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) + "…";
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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 200,Content-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);
|
||||||
|
|||||||
@ -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 200,Content-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}.
|
||||||
|
|||||||
@ -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(" "));
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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(" "));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user