mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-11-03 14:35:32 +08:00
fix(runtime): update Edge TTS handshake and preserve extended reasoning (#660)
This commit is contained in:
parent
bd88203893
commit
5642c5b1fb
@ -352,16 +352,14 @@ public class NodeStreamingChatHelper {
|
|||||||
|
|
||||||
// ==================== 重试配置 ====================
|
// ==================== 重试配置 ====================
|
||||||
|
|
||||||
|
/** Start checking for repeated reasoning only after a substantial transcript. */
|
||||||
|
private static final int THINKING_REPEAT_MIN_CHARS = 32768;
|
||||||
/**
|
/**
|
||||||
* Soft upper bound on per-call thinking ({@code reasoning_content}) chars
|
* Memory safety fallback, distinct from repetition. The local highest
|
||||||
* with zero visible content and zero tool calls. Beyond this the helper
|
* Anthropic thinking tier allows 32768 tokens, not characters; reserve
|
||||||
* disposes the upstream subscription and returns a partial result so the
|
* conservative character headroom and honor larger configured output budgets.
|
||||||
* graph can advance instead of streaming thinking forever. Calibrated
|
|
||||||
* against typical Claude/DeepSeek extended-thinking budgets — well above
|
|
||||||
* normal long-form reasoning, low enough to bound a runaway loop in under
|
|
||||||
* ~30 seconds of wall clock.
|
|
||||||
*/
|
*/
|
||||||
private static final int THINKING_ONLY_HARD_CAP_CHARS = 32768;
|
private static final long THINKING_ONLY_MIN_SAFETY_CAP_CHARS = 32768L * 16;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Narrow content-repetition guard — fires when the buffer ends with
|
* Narrow content-repetition guard — fires when the buffer ends with
|
||||||
@ -1205,11 +1203,17 @@ public class NodeStreamingChatHelper {
|
|||||||
AtomicInteger cacheWriteTokens = new AtomicInteger(0);
|
AtomicInteger cacheWriteTokens = new AtomicInteger(0);
|
||||||
AtomicInteger reasoningTokens = new AtomicInteger(0);
|
AtomicInteger reasoningTokens = new AtomicInteger(0);
|
||||||
|
|
||||||
// thinking-only soft cap 触发后设为 true,外层轮询线程据此 dispose 订阅。
|
// Detect actual reasoning loops; retain a budget-aware volume fallback.
|
||||||
// 注意:内容流的字符级 / 句子级重复检测已整体移除(设计取舍:
|
long thinkingSafetyCapChars = THINKING_ONLY_MIN_SAFETY_CAP_CHARS;
|
||||||
// agent 不替模型审核输出退化,靠 max_tokens + max_iterations 兜底);
|
var defaultOptions = ChatModelDefaults.optionsFor(chatModel);
|
||||||
// 仅保留 thinking-only 这条体积兜底,处理 volcengine-plan 等 provider
|
if (defaultOptions != null && defaultOptions.getMaxTokens() != null) {
|
||||||
// 在 thinking 通道堆字符不出 content 的死循环(生产 trace c1eefa45)。
|
thinkingSafetyCapChars = Math.max(thinkingSafetyCapChars, defaultOptions.getMaxTokens().longValue() * 16);
|
||||||
|
}
|
||||||
|
if (prompt.getOptions() != null && prompt.getOptions().getMaxTokens() != null) {
|
||||||
|
thinkingSafetyCapChars = Math.max(thinkingSafetyCapChars, prompt.getOptions().getMaxTokens().longValue() * 16);
|
||||||
|
}
|
||||||
|
final long thinkingOnlySafetyCapChars = thinkingSafetyCapChars;
|
||||||
|
AtomicInteger lastThinkingRepeatCheckLen = new AtomicInteger(0);
|
||||||
AtomicBoolean thinkingOnlyCapTriggered = new AtomicBoolean(false);
|
AtomicBoolean thinkingOnlyCapTriggered = new AtomicBoolean(false);
|
||||||
// Content-repetition guard: trips when the same paragraph-sized
|
// Content-repetition guard: trips when the same paragraph-sized
|
||||||
// suffix appears CONTENT_REPEAT_MAX_OCCURRENCES+ times in
|
// suffix appears CONTENT_REPEAT_MAX_OCCURRENCES+ times in
|
||||||
@ -1397,17 +1401,25 @@ public class NodeStreamingChatHelper {
|
|||||||
// would trip the guard before we observe the tool_call —
|
// would trip the guard before we observe the tool_call —
|
||||||
// the user would see "INCOMPLETE: thinking-only" on a
|
// the user would see "INCOMPLETE: thinking-only" on a
|
||||||
// request that was actually about to dispatch a tool.
|
// request that was actually about to dispatch a tool.
|
||||||
// Pattern-agnostic; fires on volume alone. Outer poll
|
// Long, diverse reasoning is progress: length alone must
|
||||||
// tears the subscription down within 500ms once the
|
// not classify it as a loop. Throttle suffix scans as for content.
|
||||||
// flag flips.
|
int thinkingLen = thinkingAccum.length();
|
||||||
if (thinkingAccum.length() >= THINKING_ONLY_HARD_CAP_CHARS
|
boolean repeatingThinking = false;
|
||||||
|
if (thinkingLen >= THINKING_REPEAT_MIN_CHARS
|
||||||
|
&& thinkingLen - lastThinkingRepeatCheckLen.get() >= CONTENT_REPEAT_CHECK_INTERVAL) {
|
||||||
|
lastThinkingRepeatCheckLen.set(thinkingLen);
|
||||||
|
repeatingThinking = hasRepeatingSuffix(thinkingAccum, CONTENT_REPEAT_MIN_PERIOD,
|
||||||
|
CONTENT_REPEAT_MAX_PERIOD, CONTENT_REPEAT_MAX_OCCURRENCES);
|
||||||
|
}
|
||||||
|
if ((repeatingThinking || thinkingLen >= thinkingOnlySafetyCapChars)
|
||||||
&& contentAccum.length() == 0
|
&& contentAccum.length() == 0
|
||||||
&& toolCallAccumulators.isEmpty()
|
&& toolCallAccumulators.isEmpty()
|
||||||
&& !msg.hasToolCalls()) {
|
&& !msg.hasToolCalls()) {
|
||||||
log.warn("[{}] Thinking-only soft cap reached " +
|
log.warn("[{}] Thinking-only guard reached " +
|
||||||
"({} thinking chars, no content/tool yet) " +
|
"(trigger={}, {} thinking chars, safety limit={} chars, no content/tool yet) " +
|
||||||
"— disposing stream for conversation {}",
|
"— disposing stream for conversation {}",
|
||||||
phase, thinkingAccum.length(), conversationId);
|
phase, repeatingThinking ? "repetition" : "safety_limit",
|
||||||
|
thinkingLen, thinkingOnlySafetyCapChars, conversationId);
|
||||||
broadcastContentTruncated(conversationId,
|
broadcastContentTruncated(conversationId,
|
||||||
"thinking_only_no_content",
|
"thinking_only_no_content",
|
||||||
thinkingAccum.length());
|
thinkingAccum.length());
|
||||||
|
|||||||
@ -11,14 +11,13 @@ import java.io.ByteArrayOutputStream;
|
|||||||
import java.net.URI;
|
import java.net.URI;
|
||||||
import java.net.http.HttpClient;
|
import java.net.http.HttpClient;
|
||||||
import java.net.http.WebSocket;
|
import java.net.http.WebSocket;
|
||||||
|
import java.net.http.WebSocketHandshakeException;
|
||||||
import java.nio.ByteBuffer;
|
import java.nio.ByteBuffer;
|
||||||
import java.time.Duration;
|
import java.time.Duration;
|
||||||
import java.time.Instant;
|
import java.time.Instant;
|
||||||
import java.time.ZoneOffset;
|
import java.time.ZoneOffset;
|
||||||
import java.time.format.DateTimeFormatter;
|
import java.time.format.DateTimeFormatter;
|
||||||
import java.nio.charset.StandardCharsets;
|
import java.nio.charset.StandardCharsets;
|
||||||
import java.security.MessageDigest;
|
|
||||||
import java.util.HexFormat;
|
|
||||||
import java.util.Locale;
|
import java.util.Locale;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
@ -99,8 +98,9 @@ public class EdgeTtsProvider implements TtsProvider {
|
|||||||
log.info("[Edge TTS] Synthesized {} bytes (voice={})", audioData.length, voice);
|
log.info("[Edge TTS] Synthesized {} bytes (voice={})", audioData.length, voice);
|
||||||
return TtsResult.success(audioData, "audio/mpeg", "mp3");
|
return TtsResult.success(audioData, "audio/mpeg", "mp3");
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
log.error("[Edge TTS] Synthesis error: {}", e.getMessage(), e);
|
String message = failureMessage(e);
|
||||||
return TtsResult.failure("Edge TTS 合成失败: " + e.getMessage());
|
log.warn("[Edge TTS] {}", message);
|
||||||
|
return TtsResult.failure(message);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -109,123 +109,136 @@ public class EdgeTtsProvider implements TtsProvider {
|
|||||||
String wsUrl = WS_URL + "?TrustedClientToken=" + TRUSTED_CLIENT_TOKEN
|
String wsUrl = WS_URL + "?TrustedClientToken=" + TRUSTED_CLIENT_TOKEN
|
||||||
+ "&ConnectionId=" + requestId
|
+ "&ConnectionId=" + requestId
|
||||||
+ "&Sec-MS-GEC=" + securityToken(Instant.now())
|
+ "&Sec-MS-GEC=" + securityToken(Instant.now())
|
||||||
+ "&Sec-MS-GEC-Version=1-" + EDGE_VERSION;
|
+ "&Sec-MS-GEC-Version=2-" + EDGE_VERSION;
|
||||||
|
|
||||||
ByteArrayOutputStream audioBuffer = new ByteArrayOutputStream();
|
ByteArrayOutputStream audioBuffer = new ByteArrayOutputStream();
|
||||||
CompletableFuture<byte[]> resultFuture = new CompletableFuture<>();
|
CompletableFuture<byte[]> resultFuture = new CompletableFuture<>();
|
||||||
|
|
||||||
HttpClient client = HttpClient.newBuilder()
|
try (HttpClient client = HttpClient.newBuilder()
|
||||||
.connectTimeout(Duration.ofSeconds(10))
|
.connectTimeout(Duration.ofSeconds(10))
|
||||||
.build();
|
.build()) {
|
||||||
|
|
||||||
WebSocket ws = client.newWebSocketBuilder()
|
WebSocket ws = client.newWebSocketBuilder()
|
||||||
.header("Origin", "chrome-extension://jdiccldimpdaibmpdkjnbmckianbfold")
|
.header("Origin", "chrome-extension://jdiccldimpdaibmpdkjnbmckianbfold")
|
||||||
.connectTimeout(Duration.ofSeconds(10))
|
.connectTimeout(Duration.ofSeconds(10))
|
||||||
.header("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36"
|
.header("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36"
|
||||||
+ " (KHTML, like Gecko) Chrome/143.0.0.0 Safari/537.36 Edg/143.0.0.0")
|
+ " (KHTML, like Gecko) Chrome/143.0.0.0 Safari/537.36 Edg/143.0.0.0")
|
||||||
.header("Cookie", "muid=" + UUID.randomUUID().toString().replace("-", "").toUpperCase(Locale.ROOT) + ";")
|
.header("Cookie", "muid=" + UUID.randomUUID().toString().replace("-", "").toUpperCase(Locale.ROOT) + ";")
|
||||||
.buildAsync(URI.create(wsUrl), new WebSocket.Listener() {
|
.buildAsync(URI.create(wsUrl), new WebSocket.Listener() {
|
||||||
private final StringBuilder textBuffer = new StringBuilder();
|
private final StringBuilder textBuffer = new StringBuilder();
|
||||||
private final ByteArrayOutputStream binaryBuffer = new ByteArrayOutputStream();
|
private final ByteArrayOutputStream binaryBuffer = new ByteArrayOutputStream();
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void onOpen(WebSocket webSocket) {
|
public void onOpen(WebSocket webSocket) {
|
||||||
webSocket.request(Long.MAX_VALUE);
|
webSocket.request(Long.MAX_VALUE);
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public CompletionStage<?> onText(WebSocket webSocket, CharSequence data, boolean last) {
|
|
||||||
textBuffer.append(data);
|
|
||||||
if (last) {
|
|
||||||
String msg = textBuffer.toString();
|
|
||||||
textBuffer.setLength(0);
|
|
||||||
if (msg.contains("turn.end")) {
|
|
||||||
resultFuture.complete(audioBuffer.toByteArray());
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
webSocket.request(1);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public CompletionStage<?> onBinary(WebSocket webSocket, ByteBuffer data, boolean last) {
|
public CompletionStage<?> onText(WebSocket webSocket, CharSequence data, boolean last) {
|
||||||
byte[] fragment = new byte[data.remaining()];
|
textBuffer.append(data);
|
||||||
data.get(fragment);
|
if (last) {
|
||||||
binaryBuffer.writeBytes(fragment);
|
String msg = textBuffer.toString();
|
||||||
if (last) {
|
textBuffer.setLength(0);
|
||||||
byte[] bytes = binaryBuffer.toByteArray();
|
if (msg.contains("turn.end")) {
|
||||||
binaryBuffer.reset();
|
resultFuture.complete(audioBuffer.toByteArray());
|
||||||
int headerEnd = findHeaderEnd(bytes);
|
|
||||||
if (headerEnd >= 2 && headerEnd <= bytes.length) {
|
|
||||||
String headers = new String(bytes, 2, headerEnd - 2, StandardCharsets.UTF_8);
|
|
||||||
if (headers.contains("Path:audio\r\n")) {
|
|
||||||
audioBuffer.write(bytes, headerEnd, bytes.length - headerEnd);
|
|
||||||
}
|
}
|
||||||
} else {
|
}
|
||||||
resultFuture.completeExceptionally(new IllegalStateException("Invalid Edge TTS audio frame"));
|
webSocket.request(1);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public CompletionStage<?> onBinary(WebSocket webSocket, ByteBuffer data, boolean last) {
|
||||||
|
byte[] fragment = new byte[data.remaining()];
|
||||||
|
data.get(fragment);
|
||||||
|
binaryBuffer.writeBytes(fragment);
|
||||||
|
if (last) {
|
||||||
|
byte[] bytes = binaryBuffer.toByteArray();
|
||||||
|
binaryBuffer.reset();
|
||||||
|
int headerEnd = findHeaderEnd(bytes);
|
||||||
|
if (headerEnd >= 2 && headerEnd <= bytes.length) {
|
||||||
|
String headers = new String(bytes, 2, headerEnd - 2, StandardCharsets.UTF_8);
|
||||||
|
if (headers.contains("Path:audio\r\n")) {
|
||||||
|
audioBuffer.write(bytes, headerEnd, bytes.length - headerEnd);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
resultFuture.completeExceptionally(new IllegalStateException("Invalid Edge TTS audio frame"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
webSocket.request(1);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public CompletionStage<?> onClose(WebSocket webSocket, int statusCode, String reason) {
|
||||||
|
if (!resultFuture.isDone()) {
|
||||||
|
resultFuture.completeExceptionally(new IllegalStateException(
|
||||||
|
"Edge TTS connection closed before turn.end: " + statusCode));
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onError(WebSocket webSocket, Throwable error) {
|
||||||
|
if (!resultFuture.isDone()) {
|
||||||
|
resultFuture.completeExceptionally(error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
webSocket.request(1);
|
}).join();
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
try {
|
||||||
public CompletionStage<?> onClose(WebSocket webSocket, int statusCode, String reason) {
|
String timestamp = DateTimeFormatter.ofPattern(
|
||||||
if (!resultFuture.isDone()) {
|
"EEE MMM dd yyyy HH:mm:ss 'GMT+0000 (Coordinated Universal Time)'", Locale.US)
|
||||||
resultFuture.completeExceptionally(new IllegalStateException(
|
.withZone(ZoneOffset.UTC).format(Instant.now());
|
||||||
"Edge TTS connection closed before turn.end: " + statusCode));
|
// 发送配置消息
|
||||||
}
|
String configMsg = "X-Timestamp:" + timestamp + "\r\nContent-Type:application/json; charset=utf-8\r\n"
|
||||||
return null;
|
+ "Path:speech.config\r\n\r\n"
|
||||||
}
|
+ "{\"context\":{\"synthesis\":{\"audio\":{\"metadataoptions\":{"
|
||||||
|
+ "\"sentenceBoundaryEnabled\":false,\"wordBoundaryEnabled\":false},"
|
||||||
|
+ "\"outputFormat\":\"" + OUTPUT_FORMAT + "\"}}}}\r\n";
|
||||||
|
ws.sendText(configMsg, true).join();
|
||||||
|
|
||||||
@Override
|
// 发送 SSML
|
||||||
public void onError(WebSocket webSocket, Throwable error) {
|
String escapedText = escapeXml(text);
|
||||||
if (!resultFuture.isDone()) {
|
String ssml = "<speak version='1.0' xmlns='http://www.w3.org/2001/10/synthesis' xml:lang='en-US'>"
|
||||||
resultFuture.completeExceptionally(error);
|
+ "<voice name='" + voice + "'>"
|
||||||
}
|
+ "<prosody pitch='+0Hz' rate='" + rate + "' volume='+0%'>"
|
||||||
}
|
+ escapedText
|
||||||
}).join();
|
+ "</prosody></voice></speak>";
|
||||||
|
|
||||||
try {
|
String ssmlMsg = "X-RequestId:" + requestId + "\r\n"
|
||||||
String timestamp = DateTimeFormatter.ofPattern(
|
+ "X-Timestamp:" + timestamp + "Z\r\n"
|
||||||
"EEE MMM dd yyyy HH:mm:ss 'GMT+0000 (Coordinated Universal Time)'", Locale.US)
|
+ "Content-Type:application/ssml+xml\r\n"
|
||||||
.withZone(ZoneOffset.UTC).format(Instant.now());
|
+ "Path:ssml\r\n\r\n" + ssml;
|
||||||
// 发送配置消息
|
ws.sendText(ssmlMsg, true).join();
|
||||||
String configMsg = "X-Timestamp:" + timestamp + "\r\nContent-Type:application/json; charset=utf-8\r\n"
|
|
||||||
+ "Path:speech.config\r\n\r\n"
|
|
||||||
+ "{\"context\":{\"synthesis\":{\"audio\":{\"metadataoptions\":{"
|
|
||||||
+ "\"sentenceBoundaryEnabled\":false,\"wordBoundaryEnabled\":false},"
|
|
||||||
+ "\"outputFormat\":\"" + OUTPUT_FORMAT + "\"}}}}\r\n";
|
|
||||||
ws.sendText(configMsg, true).join();
|
|
||||||
|
|
||||||
// 发送 SSML
|
// 等待结果(最多 60 秒)
|
||||||
String escapedText = escapeXml(text);
|
return resultFuture.get(60, TimeUnit.SECONDS);
|
||||||
String ssml = "<speak version='1.0' xmlns='http://www.w3.org/2001/10/synthesis' xml:lang='en-US'>"
|
} finally {
|
||||||
+ "<voice name='" + voice + "'>"
|
ws.abort();
|
||||||
+ "<prosody pitch='+0Hz' rate='" + rate + "' volume='+0%'>"
|
}
|
||||||
+ escapedText
|
|
||||||
+ "</prosody></voice></speak>";
|
|
||||||
|
|
||||||
String ssmlMsg = "X-RequestId:" + requestId + "\r\n"
|
|
||||||
+ "X-Timestamp:" + timestamp + "Z\r\n"
|
|
||||||
+ "Content-Type:application/ssml+xml\r\n"
|
|
||||||
+ "Path:ssml\r\n\r\n" + ssml;
|
|
||||||
ws.sendText(ssmlMsg, true).join();
|
|
||||||
|
|
||||||
// 等待结果(最多 60 秒)
|
|
||||||
return resultFuture.get(60, TimeUnit.SECONDS);
|
|
||||||
} finally {
|
|
||||||
ws.abort();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Edge validates a SHA-256 of the five-minute Windows FILETIME bucket and client token.
|
static String securityToken(Instant now) {
|
||||||
static String securityToken(Instant now) throws Exception {
|
return EdgeTtsSecurityToken.generate(now, TRUSTED_CLIENT_TOKEN);
|
||||||
long seconds = now.getEpochSecond() + 11_644_473_600L;
|
}
|
||||||
long ticks = (seconds - seconds % 300) * 10_000_000L;
|
|
||||||
byte[] digest = MessageDigest.getInstance("SHA-256")
|
static String failureMessage(Throwable error) {
|
||||||
.digest((ticks + TRUSTED_CLIENT_TOKEN).getBytes(StandardCharsets.US_ASCII));
|
Throwable cause = error;
|
||||||
return HexFormat.of().withUpperCase().formatHex(digest);
|
while ((cause instanceof java.util.concurrent.CompletionException
|
||||||
|
|| cause instanceof java.util.concurrent.ExecutionException) && cause.getCause() != null) {
|
||||||
|
cause = cause.getCause();
|
||||||
|
}
|
||||||
|
if (cause instanceof WebSocketHandshakeException handshake) {
|
||||||
|
int status = handshake.getResponse().statusCode();
|
||||||
|
return "Edge TTS 连接握手失败 (HTTP " + status + ")。请检查系统时间、代理或网络设置;服务协议也可能已变更。";
|
||||||
|
}
|
||||||
|
if (cause instanceof java.util.concurrent.TimeoutException) {
|
||||||
|
return "Edge TTS 合成超时,请检查网络或代理设置后重试。";
|
||||||
|
}
|
||||||
|
return "Edge TTS 合成失败 (" + cause.getClass().getSimpleName()
|
||||||
|
+ "),请检查网络或代理设置后重试。";
|
||||||
}
|
}
|
||||||
|
|
||||||
private int findHeaderEnd(byte[] data) {
|
private int findHeaderEnd(byte[] data) {
|
||||||
|
|||||||
@ -0,0 +1,169 @@
|
|||||||
|
package vip.mate.tts.provider;
|
||||||
|
|
||||||
|
import java.nio.ByteBuffer;
|
||||||
|
import java.nio.ByteOrder;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.util.HexFormat;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Edge Sec-MS-GEC v2, ported from rany2/edge-tts DRM (LGPL-3.0).
|
||||||
|
* Reference: https://github.com/rany2/edge-tts/blob/360fa8e1de354e1dda74c09bd695887d6a44df74/src/edge_tts/drm.py
|
||||||
|
* See third-party/edge-tts-NOTICE.txt and edge-tts-LICENSE.txt for attribution and license.
|
||||||
|
* Uses the upstream seeded SHA-256 compression and CS64, with Java int overflow
|
||||||
|
* providing the required arithmetic modulo 2^32.
|
||||||
|
*/
|
||||||
|
final class EdgeTtsSecurityToken {
|
||||||
|
private EdgeTtsSecurityToken() {}
|
||||||
|
private static final int[] INITIAL = {
|
||||||
|
0x6A09E667, 0xBB67AE85, 0x3C6EF372, 0xA54FF53A, 0x510E527F, 0x9B05688C, 0x1F83D9AB, 0x5BE0CD19
|
||||||
|
};
|
||||||
|
private static final int[] STATE = {
|
||||||
|
0xFD9FBEA2, 0x91243CE7, 0xBE499094, 0xCD2E1E0A, 0x6C006F49, 0x04BD35AB, 0xFEB0A7E4, 0x03EFFBD2
|
||||||
|
};
|
||||||
|
private static final int[] SCHEDULE = {
|
||||||
|
0xEB24BF9E, 0x1558691F, 0xA0C09EE2, 0x0CFA32BA, 0xA622EE8C, 0xD1DD9491, 0xC0DD25A0, 0xF9E70AB6,
|
||||||
|
0xF0C31573, 0x7058CE4E, 0x7188E606, 0x00E5AB33, 0xD258B060, 0x72766564
|
||||||
|
};
|
||||||
|
private static final int[] K = {
|
||||||
|
0x428A2F98, 0x71374491, 0xB5C0FBCF, 0xE9B5DBA5, 0x3956C25B, 0x59F111F1, 0x923F82A4, 0xAB1C5ED5,
|
||||||
|
0xD807AA98, 0x12835B01, 0x243185BE, 0x550C7DC3, 0x72BE5D74, 0x80DEB1FE, 0x9BDC06A7, 0xC19BF174,
|
||||||
|
0xE49B69C1, 0xEFBE4786, 0x0FC19DC6, 0x240CA1CC, 0x2DE92C6F, 0x4A7484AA, 0x5CB0A9DC, 0x76F988DA,
|
||||||
|
0x983E5152, 0xA831C66D, 0xB00327C8, 0xBF597FC7, 0xC6E00BF3, 0xD5A79147, 0x06CA6351, 0x14292967,
|
||||||
|
0x27B70A85, 0x2E1B2138, 0x4D2C6DFC, 0x53380D13, 0x650A7354, 0x766A0ABB, 0x81C2C92E, 0x92722C85,
|
||||||
|
0xA2BFE8A1, 0xA81A664B, 0xC24B8B70, 0xC76C51A3, 0xD192E819, 0xD6990624, 0xF40E3585, 0x106AA070,
|
||||||
|
0x19A4C116, 0x1E376C08, 0x2748774C, 0x34B0BCB5, 0x391C0CB3, 0x4ED8AA4A, 0x5B9CCA4F, 0x682E6FF3,
|
||||||
|
0x748F82EE, 0x78A5636F, 0x84C87814, 0x8CC70208, 0x90BEFFFA, 0xA4506CEB, 0xBEF9A3F7, 0xC67178F2
|
||||||
|
};
|
||||||
|
private static final int[][] COEFFICIENTS = {
|
||||||
|
{0xE82E91B1, 0xB32700D0},
|
||||||
|
{0x44DE24C6, 0x88DFF51D},
|
||||||
|
{0x3BEB65BC, 0x29514038},
|
||||||
|
{0xC89C5933, 0xB328CBDF},
|
||||||
|
{0x8D1EC211, 0xD405B4D5},
|
||||||
|
{0x314EF5D5, 0x26E55F3C},
|
||||||
|
{0xE92491B1, 0x607801C6},
|
||||||
|
{0xF0A5B9D5, 0x82B2C1FB},
|
||||||
|
{0x06064CF3, 0xFA51A3DA},
|
||||||
|
{0xDA79FA60, 0x207CC464},
|
||||||
|
{0xBED20560, 0x3C3C74F3},
|
||||||
|
{0x89F9C9F8, 0xD2D14B61},
|
||||||
|
{0x35FA38C6, 0xBB04A2C1},
|
||||||
|
{0xCB49E3C6, 0xC5626167}
|
||||||
|
};
|
||||||
|
|
||||||
|
static String generate(Instant now, String trustedClientToken) {
|
||||||
|
long seconds = now.getEpochSecond() + 11_644_473_600L;
|
||||||
|
long ticks = Math.floorDiv(seconds, 300) * 300 * 10_000_000L;
|
||||||
|
byte[] data = (ticks + trustedClientToken).getBytes(StandardCharsets.US_ASCII);
|
||||||
|
byte[] digest = sha256(data);
|
||||||
|
byte[] cs64 = cs64(data, digest);
|
||||||
|
HexFormat hex = HexFormat.of().withUpperCase();
|
||||||
|
return hex.formatHex(cs64) + "-" + hex.formatHex(digest);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int sigma0(int x) {
|
||||||
|
return Integer.rotateRight(x, 7) ^ Integer.rotateRight(x, 18) ^ (x >>> 3);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int sigma1(int x) {
|
||||||
|
return Integer.rotateRight(x, 17) ^ Integer.rotateRight(x, 19) ^ (x >>> 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void expand(int[] words, int from) {
|
||||||
|
for (int i = from; i < 64; i++) {
|
||||||
|
words[i] = sigma1(words[i - 2]) + words[i - 7] + sigma0(words[i - 15]) + words[i - 16];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int[] rounds(int[] state, int[] words, int from) {
|
||||||
|
int a = state[0], b = state[1], c = state[2], d = state[3];
|
||||||
|
int e = state[4], f = state[5], g = state[6], h = state[7];
|
||||||
|
for (int i = from; i < 64; i++) {
|
||||||
|
int constant = from == 14 && i == 14 ? 0xC9DC06A7 : K[i];
|
||||||
|
int t1 = h + (Integer.rotateRight(e, 6) ^ Integer.rotateRight(e, 11) ^ Integer.rotateRight(e, 25))
|
||||||
|
+ (g ^ (e & (f ^ g))) + constant + words[i];
|
||||||
|
int t2 = (Integer.rotateRight(a, 2) ^ Integer.rotateRight(a, 13) ^ Integer.rotateRight(a, 22))
|
||||||
|
+ ((a & b) | (c & (a | b)));
|
||||||
|
h = g; g = f; f = e; e = d + t1; d = c; c = b; b = a; a = t1 + t2;
|
||||||
|
}
|
||||||
|
return new int[]{a, b, c, d, e, f, g, h};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static byte[] sha256(byte[] data) {
|
||||||
|
int[] words = new int[64];
|
||||||
|
words[14] = (data[0] & 255) << 16 | (data[1] & 255) << 8 | data[2] & 255;
|
||||||
|
words[15] = ByteBuffer.wrap(data, 3, 4).getInt();
|
||||||
|
for (int i = 16; i < 30; i++) {
|
||||||
|
int value = SCHEDULE[i - 16];
|
||||||
|
if (i - 2 >= 14) value += sigma1(words[i - 2]) ^ (i == 16 ? 0x000B92C0 : 0);
|
||||||
|
if (i - 7 >= 14) value += words[i - 7];
|
||||||
|
if (i - 15 >= 14) value += sigma0(words[i - 15]) ^ (i == 29 ? 0x059C0B80 : 0);
|
||||||
|
words[i] = value;
|
||||||
|
}
|
||||||
|
words[30] = sigma1(words[28]) + words[23] + sigma0(words[15]) + words[14] + 0xC9DC06A7 - K[14];
|
||||||
|
expand(words, 31);
|
||||||
|
int[] state = rounds(STATE, words, 14);
|
||||||
|
for (int i = 0; i < 8; i++) state[i] += INITIAL[i];
|
||||||
|
int length = 57 + data.length;
|
||||||
|
int zeroCount = Math.floorMod(55 - length, 64);
|
||||||
|
ByteBuffer padded = ByteBuffer.allocate(data.length - 7 + 1 + zeroCount + 8);
|
||||||
|
padded.put(data, 7, data.length - 7).put((byte) 0x80);
|
||||||
|
padded.position(padded.position() + zeroCount);
|
||||||
|
padded.putLong(length * 8L).flip();
|
||||||
|
while (padded.hasRemaining()) {
|
||||||
|
for (int i = 0; i < 16; i++) words[i] = padded.getInt();
|
||||||
|
expand(words, 16);
|
||||||
|
int[] working = rounds(state, words, 0);
|
||||||
|
for (int i = 0; i < 8; i++) state[i] += working[i];
|
||||||
|
}
|
||||||
|
ByteBuffer result = ByteBuffer.allocate(32);
|
||||||
|
for (int word : state) result.putInt(word);
|
||||||
|
return result.array();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static byte[] cs64(byte[] data, byte[] digest) {
|
||||||
|
ByteBuffer keys = ByteBuffer.wrap(digest).order(ByteOrder.LITTLE_ENDIAN);
|
||||||
|
int keyA = keys.getInt() | 1, keyB = keys.getInt() | 1;
|
||||||
|
int[] factors = {keyA * 0x82893933, keyB * 0x82893933};
|
||||||
|
int wordCount = ((data.length + 1 + 7) / 8) * 2;
|
||||||
|
int[] terms = new int[14 + wordCount], offsets = new int[terms.length];
|
||||||
|
for (int i = 0; i < 14; i++) {
|
||||||
|
terms[i] = COEFFICIENTS[i][0] * factors[i % 2];
|
||||||
|
offsets[i] = COEFFICIENTS[i][1];
|
||||||
|
}
|
||||||
|
ByteBuffer input = ByteBuffer.allocate(wordCount * 4).order(ByteOrder.LITTLE_ENDIAN);
|
||||||
|
input.put((byte) 0).put(data).rewind();
|
||||||
|
for (int i = 0; i < wordCount; i++) {
|
||||||
|
int word = input.getInt();
|
||||||
|
terms[14 + i] = word * (i % 2 == 0 ? keyA : keyB);
|
||||||
|
offsets[14 + i] = Integer.rotateLeft(word, 16) * (i % 2 == 0 ? 0xEF0569FB : 0xC31713DB);
|
||||||
|
if (i == 0) {
|
||||||
|
terms[14] += 0xF2F90B1A * factors[0];
|
||||||
|
offsets[14] += 0x0B1A0000;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
int swapAcc = 0, swapLast = 0, state = 0, acc = 0;
|
||||||
|
for (int i = 0; i < terms.length; i += 2) {
|
||||||
|
int x = terms[i], y = terms[i + 1];
|
||||||
|
int q = Integer.rotateLeft(offsets[i] + x, 16) * 0x76F50F34;
|
||||||
|
swapAcc += Integer.rotateLeft(q, 16) * 0xC3EFEA97 + q;
|
||||||
|
q = Integer.rotateLeft(offsets[i + 1] + y, 16) * 0x1881CE3C;
|
||||||
|
swapLast = Integer.rotateLeft(q, 16) * 0x35BD1EC9 + q;
|
||||||
|
swapAcc += swapLast;
|
||||||
|
state = state * keyA + x;
|
||||||
|
state = Integer.rotateLeft(state, 16) * 0xCF98B111;
|
||||||
|
state = Integer.rotateLeft(state, 16) * 0x87085B9F;
|
||||||
|
state = Integer.rotateLeft(state, 16) * 0x12CEB96D;
|
||||||
|
state = Integer.rotateLeft(state, 16) * 0x257E1D83;
|
||||||
|
acc += state;
|
||||||
|
int t = state * keyB + y;
|
||||||
|
t = Integer.rotateLeft(t, 16) * 0xA27416F5;
|
||||||
|
t = Integer.rotateLeft(t, 16) * 0xD38396FF;
|
||||||
|
state = Integer.rotateLeft(Integer.rotateLeft(t, 16) * 0x7C932B89, 16) * 0xBFA49F69;
|
||||||
|
acc += state;
|
||||||
|
}
|
||||||
|
return ByteBuffer.allocate(8).order(ByteOrder.LITTLE_ENDIAN)
|
||||||
|
.putInt(swapLast ^ state).putInt(swapAcc ^ acc).array();
|
||||||
|
}
|
||||||
|
}
|
||||||
195
mateclaw-server/src/main/java/vip/mate/tts/provider/third-party/edge-tts-LICENSE.txt
vendored
Normal file
195
mateclaw-server/src/main/java/vip/mate/tts/provider/third-party/edge-tts-LICENSE.txt
vendored
Normal file
@ -0,0 +1,195 @@
|
|||||||
|
The MIT license is used for 'src/edge_tts/srt_composer.py' only. All
|
||||||
|
remaining files are licensed under the LGPLv3.
|
||||||
|
|
||||||
|
-----------------------------------------------------------------------
|
||||||
|
|
||||||
|
The MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2014-2023 Christopher Down
|
||||||
|
Copyright (c) 2025- rany <rany@riseup.net>
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in
|
||||||
|
all copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||||
|
THE SOFTWARE.
|
||||||
|
|
||||||
|
-----------------------------------------------------------------------
|
||||||
|
|
||||||
|
GNU LESSER GENERAL PUBLIC LICENSE
|
||||||
|
Version 3, 29 June 2007
|
||||||
|
|
||||||
|
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
|
||||||
|
Everyone is permitted to copy and distribute verbatim copies
|
||||||
|
of this license document, but changing it is not allowed.
|
||||||
|
|
||||||
|
|
||||||
|
This version of the GNU Lesser General Public License incorporates
|
||||||
|
the terms and conditions of version 3 of the GNU General Public
|
||||||
|
License, supplemented by the additional permissions listed below.
|
||||||
|
|
||||||
|
0. Additional Definitions.
|
||||||
|
|
||||||
|
As used herein, "this License" refers to version 3 of the GNU Lesser
|
||||||
|
General Public License, and the "GNU GPL" refers to version 3 of the GNU
|
||||||
|
General Public License.
|
||||||
|
|
||||||
|
"The Library" refers to a covered work governed by this License,
|
||||||
|
other than an Application or a Combined Work as defined below.
|
||||||
|
|
||||||
|
An "Application" is any work that makes use of an interface provided
|
||||||
|
by the Library, but which is not otherwise based on the Library.
|
||||||
|
Defining a subclass of a class defined by the Library is deemed a mode
|
||||||
|
of using an interface provided by the Library.
|
||||||
|
|
||||||
|
A "Combined Work" is a work produced by combining or linking an
|
||||||
|
Application with the Library. The particular version of the Library
|
||||||
|
with which the Combined Work was made is also called the "Linked
|
||||||
|
Version".
|
||||||
|
|
||||||
|
The "Minimal Corresponding Source" for a Combined Work means the
|
||||||
|
Corresponding Source for the Combined Work, excluding any source code
|
||||||
|
for portions of the Combined Work that, considered in isolation, are
|
||||||
|
based on the Application, and not on the Linked Version.
|
||||||
|
|
||||||
|
The "Corresponding Application Code" for a Combined Work means the
|
||||||
|
object code and/or source code for the Application, including any data
|
||||||
|
and utility programs needed for reproducing the Combined Work from the
|
||||||
|
Application, but excluding the System Libraries of the Combined Work.
|
||||||
|
|
||||||
|
1. Exception to Section 3 of the GNU GPL.
|
||||||
|
|
||||||
|
You may convey a covered work under sections 3 and 4 of this License
|
||||||
|
without being bound by section 3 of the GNU GPL.
|
||||||
|
|
||||||
|
2. Conveying Modified Versions.
|
||||||
|
|
||||||
|
If you modify a copy of the Library, and, in your modifications, a
|
||||||
|
facility refers to a function or data to be supplied by an Application
|
||||||
|
that uses the facility (other than as an argument passed when the
|
||||||
|
facility is invoked), then you may convey a copy of the modified
|
||||||
|
version:
|
||||||
|
|
||||||
|
a) under this License, provided that you make a good faith effort to
|
||||||
|
ensure that, in the event an Application does not supply the
|
||||||
|
function or data, the facility still operates, and performs
|
||||||
|
whatever part of its purpose remains meaningful, or
|
||||||
|
|
||||||
|
b) under the GNU GPL, with none of the additional permissions of
|
||||||
|
this License applicable to that copy.
|
||||||
|
|
||||||
|
3. Object Code Incorporating Material from Library Header Files.
|
||||||
|
|
||||||
|
The object code form of an Application may incorporate material from
|
||||||
|
a header file that is part of the Library. You may convey such object
|
||||||
|
code under terms of your choice, provided that, if the incorporated
|
||||||
|
material is not limited to numerical parameters, data structure
|
||||||
|
layouts and accessors, or small macros, inline functions and templates
|
||||||
|
(ten or fewer lines in length), you do both of the following:
|
||||||
|
|
||||||
|
a) Give prominent notice with each copy of the object code that the
|
||||||
|
Library is used in it and that the Library and its use are
|
||||||
|
covered by this License.
|
||||||
|
|
||||||
|
b) Accompany the object code with a copy of the GNU GPL and this license
|
||||||
|
document.
|
||||||
|
|
||||||
|
4. Combined Works.
|
||||||
|
|
||||||
|
You may convey a Combined Work under terms of your choice that,
|
||||||
|
taken together, effectively do not restrict modification of the
|
||||||
|
portions of the Library contained in the Combined Work and reverse
|
||||||
|
engineering for debugging such modifications, if you also do each of
|
||||||
|
the following:
|
||||||
|
|
||||||
|
a) Give prominent notice with each copy of the Combined Work that
|
||||||
|
the Library is used in it and that the Library and its use are
|
||||||
|
covered by this License.
|
||||||
|
|
||||||
|
b) Accompany the Combined Work with a copy of the GNU GPL and this license
|
||||||
|
document.
|
||||||
|
|
||||||
|
c) For a Combined Work that displays copyright notices during
|
||||||
|
execution, include the copyright notice for the Library among
|
||||||
|
these notices, as well as a reference directing the user to the
|
||||||
|
copies of the GNU GPL and this license document.
|
||||||
|
|
||||||
|
d) Do one of the following:
|
||||||
|
|
||||||
|
0) Convey the Minimal Corresponding Source under the terms of this
|
||||||
|
License, and the Corresponding Application Code in a form
|
||||||
|
suitable for, and under terms that permit, the user to
|
||||||
|
recombine or relink the Application with a modified version of
|
||||||
|
the Linked Version to produce a modified Combined Work, in the
|
||||||
|
manner specified by section 6 of the GNU GPL for conveying
|
||||||
|
Corresponding Source.
|
||||||
|
|
||||||
|
1) Use a suitable shared library mechanism for linking with the
|
||||||
|
Library. A suitable mechanism is one that (a) uses at run time
|
||||||
|
a copy of the Library already present on the user's computer
|
||||||
|
system, and (b) will operate properly with a modified version
|
||||||
|
of the Library that is interface-compatible with the Linked
|
||||||
|
Version.
|
||||||
|
|
||||||
|
e) Provide Installation Information, but only if you would otherwise
|
||||||
|
be required to provide such information under section 6 of the
|
||||||
|
GNU GPL, and only to the extent that such information is
|
||||||
|
necessary to install and execute a modified version of the
|
||||||
|
Combined Work produced by recombining or relinking the
|
||||||
|
Application with a modified version of the Linked Version. (If
|
||||||
|
you use option 4d0, the Installation Information must accompany
|
||||||
|
the Minimal Corresponding Source and Corresponding Application
|
||||||
|
Code. If you use option 4d1, you must provide the Installation
|
||||||
|
Information in the manner specified by section 6 of the GNU GPL
|
||||||
|
for conveying Corresponding Source.)
|
||||||
|
|
||||||
|
5. Combined Libraries.
|
||||||
|
|
||||||
|
You may place library facilities that are a work based on the
|
||||||
|
Library side by side in a single library together with other library
|
||||||
|
facilities that are not Applications and are not covered by this
|
||||||
|
License, and convey such a combined library under terms of your
|
||||||
|
choice, if you do both of the following:
|
||||||
|
|
||||||
|
a) Accompany the combined library with a copy of the same work based
|
||||||
|
on the Library, uncombined with any other library facilities,
|
||||||
|
conveyed under the terms of this License.
|
||||||
|
|
||||||
|
b) Give prominent notice with the combined library that part of it
|
||||||
|
is a work based on the Library, and explaining where to find the
|
||||||
|
accompanying uncombined form of the same work.
|
||||||
|
|
||||||
|
6. Revised Versions of the GNU Lesser General Public License.
|
||||||
|
|
||||||
|
The Free Software Foundation may publish revised and/or new versions
|
||||||
|
of the GNU Lesser General Public License from time to time. Such new
|
||||||
|
versions will be similar in spirit to the present version, but may
|
||||||
|
differ in detail to address new problems or concerns.
|
||||||
|
|
||||||
|
Each version is given a distinguishing version number. If the
|
||||||
|
Library as you received it specifies that a certain numbered version
|
||||||
|
of the GNU Lesser General Public License "or any later version"
|
||||||
|
applies to it, you have the option of following the terms and
|
||||||
|
conditions either of that published version or of any later version
|
||||||
|
published by the Free Software Foundation. If the Library as you
|
||||||
|
received it does not specify a version number of the GNU Lesser
|
||||||
|
General Public License, you may choose any version of the GNU Lesser
|
||||||
|
General Public License ever published by the Free Software Foundation.
|
||||||
|
|
||||||
|
If the Library as you received it specifies that a proxy can decide
|
||||||
|
whether future versions of the GNU Lesser General Public License shall
|
||||||
|
apply, that proxy's public statement of acceptance of any version is
|
||||||
|
permanent authorization for you to choose that version for the
|
||||||
|
Library.
|
||||||
19
mateclaw-server/src/main/java/vip/mate/tts/provider/third-party/edge-tts-NOTICE.txt
vendored
Normal file
19
mateclaw-server/src/main/java/vip/mate/tts/provider/third-party/edge-tts-NOTICE.txt
vendored
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
EdgeTtsSecurityToken.java contains a Java port of the Sec-MS-GEC v2
|
||||||
|
algorithm from edge-tts src/edge_tts/drm.py.
|
||||||
|
|
||||||
|
Upstream project: https://github.com/rany2/edge-tts
|
||||||
|
Author: rany (ranygh@riseup.net), as identified in upstream setup.cfg.
|
||||||
|
Sec-MS-GEC v2 contribution: HarryXiaoCN, upstream pull request #492.
|
||||||
|
Reference revision: 360fa8e1de354e1dda74c09bd695887d6a44df74
|
||||||
|
Reference source:
|
||||||
|
https://github.com/rany2/edge-tts/blob/360fa8e1de354e1dda74c09bd695887d6a44df74/src/edge_tts/drm.py
|
||||||
|
|
||||||
|
The upstream drm.py does not specify a separate copyright notice.
|
||||||
|
The upstream LICENSE is reproduced verbatim in edge-tts-LICENSE.txt;
|
||||||
|
it states that all files other than srt_composer.py are licensed under
|
||||||
|
GNU Lesser General Public License version 3. Copyright notices in that
|
||||||
|
file are preserved verbatim and apply as described there.
|
||||||
|
|
||||||
|
Changes: translated the Python algorithm into Java using int overflow
|
||||||
|
for modulo 2^32 arithmetic and Instant for five-minute FILETIME buckets.
|
||||||
|
This derived utility is licensed under LGPLv3 as the upstream source.
|
||||||
@ -21,19 +21,7 @@ import static org.mockito.ArgumentMatchers.any;
|
|||||||
import static org.mockito.Mockito.mock;
|
import static org.mockito.Mockito.mock;
|
||||||
import static org.mockito.Mockito.when;
|
import static org.mockito.Mockito.when;
|
||||||
|
|
||||||
/**
|
/** Regression coverage for reasoning loops, extended budgets, and tool transitions. */
|
||||||
* Regression coverage for the thinking-only soft cap added in P0-2.
|
|
||||||
*
|
|
||||||
* <p>The cap disposes the upstream stream when the model has emitted
|
|
||||||
* {@code >= THINKING_ONLY_HARD_CAP_CHARS} of {@code reasoning_content}
|
|
||||||
* with zero visible content and zero tool calls. The risk noted during
|
|
||||||
* review (P1-A): some providers (Anthropic / DeepSeek-thinking variants)
|
|
||||||
* pack {@code reasoning_content} and a {@code tool_call} into the same
|
|
||||||
* SSE chunk. If the cap check sits inside the thinking-delta block (i.e.
|
|
||||||
* before the chunk's tool_call is accumulated) it would dispose just
|
|
||||||
* before observing the tool — turning a request that was about to dispatch
|
|
||||||
* a tool into a spurious "INCOMPLETE: thinking-only" outcome.
|
|
||||||
*/
|
|
||||||
class NodeStreamingChatHelperThinkingCapTest {
|
class NodeStreamingChatHelperThinkingCapTest {
|
||||||
|
|
||||||
private ChatStreamTracker streamTracker;
|
private ChatStreamTracker streamTracker;
|
||||||
@ -112,6 +100,74 @@ class NodeStreamingChatHelperThinkingCapTest {
|
|||||||
assertEquals(hugeThinking, result.thinking(),
|
assertEquals(hugeThinking, result.thinking(),
|
||||||
"Thinking transcript is preserved so the UI can show it in a collapse panel");
|
"Thinking transcript is preserved so the UI can show it in a collapse panel");
|
||||||
}
|
}
|
||||||
|
@Test
|
||||||
|
void diverseLongThinkingCanReachAnswerInLaterChunk() {
|
||||||
|
assertLongThinkingProgress(AssistantMessage.builder().content("Finished analysis").build());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void diverseLongThinkingCanReachToolInLaterChunk() {
|
||||||
|
assertLongThinkingProgress(AssistantMessage.builder().content("")
|
||||||
|
.toolCalls(List.of(new AssistantMessage.ToolCall("later", "function", "search", "{}"))).build());
|
||||||
|
}
|
||||||
|
|
||||||
|
private void assertLongThinkingProgress(AssistantMessage progress) {
|
||||||
|
StringBuilder reasoning = new StringBuilder();
|
||||||
|
for (int i = 0; i < 1400; i++) {
|
||||||
|
reasoning.append("Consider case ").append(i).append(": compare evidence ")
|
||||||
|
.append(i * 17).append(" with alternative ").append(i * 31).append(".\n");
|
||||||
|
}
|
||||||
|
assertTrue(reasoning.length() > 32768);
|
||||||
|
var thinking = AssistantMessage.builder().content("")
|
||||||
|
.properties(Map.of("reasoningContent", reasoning.toString())).build();
|
||||||
|
var model = mock(ChatModel.class);
|
||||||
|
when(model.stream(any(Prompt.class))).thenReturn(Flux.just(
|
||||||
|
new ChatResponse(List.of(new Generation(thinking))),
|
||||||
|
new ChatResponse(List.of(new Generation(progress)))));
|
||||||
|
var result = new NodeStreamingChatHelper(streamTracker)
|
||||||
|
.streamCall(model, smallPrompt(), "conv-long-valid", "reasoning");
|
||||||
|
assertFalse(result.partial(), "Diverse reasoning must be allowed to finish");
|
||||||
|
assertEquals(reasoning.toString(), result.thinking());
|
||||||
|
assertEquals(progress.getText(), result.text());
|
||||||
|
assertEquals(progress.hasToolCalls(), result.hasToolCalls());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void diverseThinkingStillHasFiniteMemorySafetyBound() {
|
||||||
|
StringBuilder reasoning = new StringBuilder();
|
||||||
|
for (int i = 0; i < 15000; i++) {
|
||||||
|
reasoning.append("Evidence ").append(i).append(" differs from ").append(i * 31)
|
||||||
|
.append(" so compare the next independent case.\n");
|
||||||
|
}
|
||||||
|
var message = AssistantMessage.builder().content("")
|
||||||
|
.properties(Map.of("reasoningContent", reasoning.toString())).build();
|
||||||
|
var result = new NodeStreamingChatHelper(streamTracker).streamCall(
|
||||||
|
singleChunkModel(message), smallPrompt(), "conv-memory-bound", "reasoning");
|
||||||
|
assertTrue(result.partial());
|
||||||
|
assertEquals("thinking_only_no_content", result.errorMessage());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void largerConfiguredTokenBudgetRaisesThinkingSafetyBound() {
|
||||||
|
StringBuilder reasoning = new StringBuilder();
|
||||||
|
for (int i = 0; i < 15000; i++) {
|
||||||
|
reasoning.append("Evidence ").append(i).append(" differs from ").append(i * 31)
|
||||||
|
.append(" so compare the next independent case.\n");
|
||||||
|
}
|
||||||
|
var thinking = AssistantMessage.builder().content("")
|
||||||
|
.properties(Map.of("reasoningContent", reasoning.toString())).build();
|
||||||
|
var model = mock(ChatModel.class);
|
||||||
|
when(model.stream(any(Prompt.class))).thenReturn(Flux.just(
|
||||||
|
new ChatResponse(List.of(new Generation(thinking))),
|
||||||
|
new ChatResponse(List.of(new Generation(new AssistantMessage("Finished"))))));
|
||||||
|
var prompt = new Prompt(List.of(new UserMessage("hi")),
|
||||||
|
org.springframework.ai.chat.prompt.ChatOptions.builder().maxTokens(100000).build());
|
||||||
|
var result = new NodeStreamingChatHelper(streamTracker)
|
||||||
|
.streamCall(model, prompt, "conv-configured-budget", "reasoning");
|
||||||
|
assertFalse(result.partial());
|
||||||
|
assertEquals("Finished", result.text());
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void reasoningOnlyTokenLimitIsIncompleteRatherThanEmptyOrSuccessful() {
|
void reasoningOnlyTokenLimitIsIncompleteRatherThanEmptyOrSuccessful() {
|
||||||
var message = AssistantMessage.builder().content("")
|
var message = AssistantMessage.builder().content("")
|
||||||
|
|||||||
@ -20,14 +20,39 @@ import static org.mockito.Mockito.*;
|
|||||||
class EdgeTtsProviderTest {
|
class EdgeTtsProviderTest {
|
||||||
@Test
|
@Test
|
||||||
void hashesWindowsFileTimeInFiveMinuteBuckets() throws Exception {
|
void hashesWindowsFileTimeInFiveMinuteBuckets() throws Exception {
|
||||||
assertEquals("7ECB79D14E3AA576D2D79E6D487A1388156D91E614B1BE11C64226A29BC8DD8C",
|
assertEquals("492D7694A183A45E-56E821F95D4579C5B7C7E33DC7303FF379EC36D510213555BBBB9EB17EF15876",
|
||||||
EdgeTtsProvider.securityToken(java.time.Instant.EPOCH));
|
EdgeTtsProvider.securityToken(java.time.Instant.ofEpochSecond(1791888000)));
|
||||||
assertEquals(EdgeTtsProvider.securityToken(java.time.Instant.EPOCH),
|
assertEquals(EdgeTtsProvider.securityToken(java.time.Instant.EPOCH),
|
||||||
EdgeTtsProvider.securityToken(java.time.Instant.ofEpochSecond(299)));
|
EdgeTtsProvider.securityToken(java.time.Instant.ofEpochSecond(299)));
|
||||||
assertNotEquals(EdgeTtsProvider.securityToken(java.time.Instant.EPOCH),
|
assertNotEquals(EdgeTtsProvider.securityToken(java.time.Instant.EPOCH),
|
||||||
EdgeTtsProvider.securityToken(java.time.Instant.ofEpochSecond(300)));
|
EdgeTtsProvider.securityToken(java.time.Instant.ofEpochSecond(300)));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void matchesUpstreamV2ReferenceVectors() {
|
||||||
|
// Values produced by upstream edge_tts.drm.generate_sec_ms_gec_v2.
|
||||||
|
assertEquals("52BC7A17B50DFEC9-28C4394775C8751CD2F6859C030C06DB1489ACAB3B3A7651831FC26895177ED6",
|
||||||
|
EdgeTtsProvider.securityToken(java.time.Instant.EPOCH));
|
||||||
|
assertEquals("F7535A20A262E331-28675BF85C7106BBF20035A78FCB0B6890813EAD5C3B983EF3424931B720BABB",
|
||||||
|
EdgeTtsProvider.securityToken(java.time.Instant.ofEpochSecond(300)));
|
||||||
|
assertEquals("2CD23A2CA0B2595A-F3C846C370E05F9CC83ACC5F995C5F5D87A4D0F14CE8936A0F902E64099E38A2",
|
||||||
|
EdgeTtsProvider.securityToken(java.time.Instant.ofEpochSecond(1700000000)));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void reportsHandshakeStatusWithoutExposingResponseUri() {
|
||||||
|
java.net.http.HttpResponse<?> response = mock(java.net.http.HttpResponse.class);
|
||||||
|
when(response.statusCode()).thenReturn(403);
|
||||||
|
var failure = new java.util.concurrent.CompletionException(
|
||||||
|
new java.net.http.WebSocketHandshakeException(response));
|
||||||
|
String message = EdgeTtsProvider.failureMessage(failure);
|
||||||
|
assertTrue(message.contains("HTTP 403"));
|
||||||
|
assertTrue(message.contains("系统时间"));
|
||||||
|
assertFalse(message.contains("TrustedClientToken"));
|
||||||
|
assertFalse(message.contains("Sec-MS-GEC"));
|
||||||
|
verify(response, never()).uri();
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void usesCurrentHandshakeAndPreservesFragmentedAudio() {
|
void usesCurrentHandshakeAndPreservesFragmentedAudio() {
|
||||||
HttpClient.Builder httpBuilder = mock(HttpClient.Builder.class, RETURNS_SELF);
|
HttpClient.Builder httpBuilder = mock(HttpClient.Builder.class, RETURNS_SELF);
|
||||||
@ -59,11 +84,12 @@ class EdgeTtsProviderTest {
|
|||||||
var result = new EdgeTtsProvider().synthesize(
|
var result = new EdgeTtsProvider().synthesize(
|
||||||
TtsRequest.builder().text("Hello").build(), new SystemSettingsDTO());
|
TtsRequest.builder().text("Hello").build(), new SystemSettingsDTO());
|
||||||
assertEquals("/consumer/speech/synthesize/readaloud/edge/v1", endpoint.get().getPath());
|
assertEquals("/consumer/speech/synthesize/readaloud/edge/v1", endpoint.get().getPath());
|
||||||
assertTrue(endpoint.get().getQuery().matches(".*Sec-MS-GEC=[A-F0-9]{64}.*"));
|
assertTrue(endpoint.get().getQuery().matches(".*Sec-MS-GEC=[A-F0-9]{16}-[A-F0-9]{64}.*"));
|
||||||
assertTrue(endpoint.get().getQuery().contains("Sec-MS-GEC-Version=1-"));
|
assertTrue(endpoint.get().getQuery().contains("Sec-MS-GEC-Version=2-"));
|
||||||
assertTrue(result.isSuccess(), result.getErrorMessage());
|
assertTrue(result.isSuccess(), result.getErrorMessage());
|
||||||
assertArrayEquals(new byte[]{1, 2, 3, 4}, result.getAudioData());
|
assertArrayEquals(new byte[]{1, 2, 3, 4}, result.getAudioData());
|
||||||
verify(socket).abort();
|
verify(socket).abort();
|
||||||
|
verify(client).close();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -3,6 +3,8 @@ import { createI18n } from 'vue-i18n'
|
|||||||
import { createPinia } from 'pinia'
|
import { createPinia } from 'pinia'
|
||||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||||
import type { Message } from '@/types'
|
import type { Message } from '@/types'
|
||||||
|
import zhCN from '@/i18n/locales/zh-CN'
|
||||||
|
import enUS from '@/i18n/locales/en-US'
|
||||||
|
|
||||||
vi.mock('@/composables/useStreamingMarkdown', () => ({
|
vi.mock('@/composables/useStreamingMarkdown', () => ({
|
||||||
useStreamingMarkdown: (content: unknown) => ({
|
useStreamingMarkdown: (content: unknown) => ({
|
||||||
@ -74,12 +76,16 @@ function mountMessage(message: Message, locale = 'zh-CN') {
|
|||||||
messages: {
|
messages: {
|
||||||
'zh-CN': {
|
'zh-CN': {
|
||||||
chat: {
|
chat: {
|
||||||
|
incompleteTitle: zhCN.chat.incompleteTitle,
|
||||||
|
incompleteDescription: zhCN.chat.incompleteDescription,
|
||||||
stopped: '已被用户手动中止',
|
stopped: '已被用户手动中止',
|
||||||
interrupted: '已中断并继续处理下一条消息',
|
interrupted: '已中断并继续处理下一条消息',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
en: {
|
en: {
|
||||||
chat: {
|
chat: {
|
||||||
|
incompleteTitle: enUS.chat.incompleteTitle,
|
||||||
|
incompleteDescription: enUS.chat.incompleteDescription,
|
||||||
stopped: 'Stopped manually by user',
|
stopped: 'Stopped manually by user',
|
||||||
interrupted: 'Interrupted and continued with the next message',
|
interrupted: 'Interrupted and continued with the next message',
|
||||||
},
|
},
|
||||||
@ -201,3 +207,16 @@ describe('MessageBubble TTS (#646)', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
|
describe('MessageBubble incomplete answer (#660)', () => {
|
||||||
|
it.each(['zh-CN', 'en'])('does not diagnose token-budget exhaustion as repetition (%s)', locale => {
|
||||||
|
const host = mountMessage({ id: 'budget', conversationId: 'conv', role: 'assistant',
|
||||||
|
content: '模型在输出最终答案前已耗尽输出 token 预算', contentParts: [],
|
||||||
|
status: 'completed', metadata: { finishReason: 'incomplete' } } as Message, locale)
|
||||||
|
const title = host.querySelector('.incomplete-card__title')?.textContent || ''
|
||||||
|
expect(title).not.toMatch(/重复|repeated|repetition/i)
|
||||||
|
expect(host.textContent).toContain('token')
|
||||||
|
expect(host.querySelector('.incomplete-card__retry')).not.toBeNull()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
@ -699,8 +699,8 @@ export default {
|
|||||||
pendingReply: 'Preparing a reply…',
|
pendingReply: 'Preparing a reply…',
|
||||||
expand: 'Expand',
|
expand: 'Expand',
|
||||||
// INCOMPLETE truncation card (finishReason=incomplete)
|
// INCOMPLETE truncation card (finishReason=incomplete)
|
||||||
incompleteTitle: 'Answer auto-truncated after repeated output was detected',
|
incompleteTitle: 'Answer is incomplete',
|
||||||
incompleteDescription: 'After the visible text above, the model started repeating itself (or stalled in thinking with no output) and was cut short to avoid wasted tokens. Click below to regenerate the full answer, or refine your prompt to focus on the missing parts.',
|
incompleteDescription: 'This answer did not finish. Check the message for the reason: repeated output, a reasoning safety limit, or an exhausted output token budget. Regenerate, adjust thinking and output budgets, or split the task to continue.',
|
||||||
incompleteRetry: 'Regenerate',
|
incompleteRetry: 'Regenerate',
|
||||||
// EVIDENCE_INSUFFICIENT info card (finishReason=evidence_insufficient)
|
// EVIDENCE_INSUFFICIENT info card (finishReason=evidence_insufficient)
|
||||||
evidenceTitle: 'Run completed — some source references could not be verified',
|
evidenceTitle: 'Run completed — some source references could not be verified',
|
||||||
|
|||||||
@ -699,8 +699,8 @@ export default {
|
|||||||
pendingReply: '正在准备回复…',
|
pendingReply: '正在准备回复…',
|
||||||
expand: '展开',
|
expand: '展开',
|
||||||
// INCOMPLETE 截断卡片(finishReason=incomplete)
|
// INCOMPLETE 截断卡片(finishReason=incomplete)
|
||||||
incompleteTitle: '回答因检测到重复输出已被自动截断',
|
incompleteTitle: '回答尚未完成',
|
||||||
incompleteDescription: '系统在你看到的部分之后检测到模型开始重复同一段内容(或在思考阶段无产出),已自动截断以避免无效输出。点下方按钮重新生成完整回答,或在输入框补充提示让模型聚焦剩余内容。',
|
incompleteDescription: '本次回答未完成,具体原因请查看消息内容:可能是重复输出触发保护、思考达到安全上限,或模型耗尽输出 token 预算。可重新生成、调整思考和输出预算,或拆分任务后继续。',
|
||||||
incompleteRetry: '重新生成',
|
incompleteRetry: '重新生成',
|
||||||
// EVIDENCE_INSUFFICIENT 提示卡(finishReason=evidence_insufficient)
|
// EVIDENCE_INSUFFICIENT 提示卡(finishReason=evidence_insufficient)
|
||||||
evidenceTitle: '任务已完成,但部分源码引用未被验证',
|
evidenceTitle: '任务已完成,但部分源码引用未被验证',
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user