fix(runtime): update Edge TTS handshake and preserve extended reasoning (#660)

This commit is contained in:
matevip 2026-10-13 21:16:12 -04:00
parent bd88203893
commit 5642c5b1fb
10 changed files with 652 additions and 143 deletions

View File

@ -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
* with zero visible content and zero tool calls. Beyond this the helper
* disposes the upstream subscription and returns a partial result so the
* 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.
* Memory safety fallback, distinct from repetition. The local highest
* Anthropic thinking tier allows 32768 tokens, not characters; reserve
* conservative character headroom and honor larger configured output budgets.
*/
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
@ -1205,11 +1203,17 @@ public class NodeStreamingChatHelper {
AtomicInteger cacheWriteTokens = new AtomicInteger(0);
AtomicInteger reasoningTokens = new AtomicInteger(0);
// thinking-only soft cap 触发后设为 true,外层轮询线程据此 dispose 订阅。
// 注意:内容流的字符级 / 句子级重复检测已整体移除(设计取舍:
// agent 不替模型审核输出退化,靠 max_tokens + max_iterations 兜底);
// 仅保留 thinking-only 这条体积兜底,处理 volcengine-plan 等 provider
// 在 thinking 通道堆字符不出 content 的死循环(生产 trace c1eefa45)。
// Detect actual reasoning loops; retain a budget-aware volume fallback.
long thinkingSafetyCapChars = THINKING_ONLY_MIN_SAFETY_CAP_CHARS;
var defaultOptions = ChatModelDefaults.optionsFor(chatModel);
if (defaultOptions != null && defaultOptions.getMaxTokens() != null) {
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);
// Content-repetition guard: trips when the same paragraph-sized
// 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 —
// the user would see "INCOMPLETE: thinking-only" on a
// request that was actually about to dispatch a tool.
// Pattern-agnostic; fires on volume alone. Outer poll
// tears the subscription down within 500ms once the
// flag flips.
if (thinkingAccum.length() >= THINKING_ONLY_HARD_CAP_CHARS
// Long, diverse reasoning is progress: length alone must
// not classify it as a loop. Throttle suffix scans as for content.
int thinkingLen = thinkingAccum.length();
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
&& toolCallAccumulators.isEmpty()
&& !msg.hasToolCalls()) {
log.warn("[{}] Thinking-only soft cap reached " +
"({} thinking chars, no content/tool yet) " +
log.warn("[{}] Thinking-only guard reached " +
"(trigger={}, {} thinking chars, safety limit={} chars, no content/tool yet) " +
"— disposing stream for conversation {}",
phase, thinkingAccum.length(), conversationId);
phase, repeatingThinking ? "repetition" : "safety_limit",
thinkingLen, thinkingOnlySafetyCapChars, conversationId);
broadcastContentTruncated(conversationId,
"thinking_only_no_content",
thinkingAccum.length());

View File

@ -11,14 +11,13 @@ import java.io.ByteArrayOutputStream;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.WebSocket;
import java.net.http.WebSocketHandshakeException;
import java.nio.ByteBuffer;
import java.time.Duration;
import java.time.Instant;
import java.time.ZoneOffset;
import java.time.format.DateTimeFormatter;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.util.HexFormat;
import java.util.Locale;
import java.util.List;
import java.util.UUID;
@ -99,8 +98,9 @@ public class EdgeTtsProvider implements TtsProvider {
log.info("[Edge TTS] Synthesized {} bytes (voice={})", audioData.length, voice);
return TtsResult.success(audioData, "audio/mpeg", "mp3");
} catch (Exception e) {
log.error("[Edge TTS] Synthesis error: {}", e.getMessage(), e);
return TtsResult.failure("Edge TTS 合成失败: " + e.getMessage());
String message = failureMessage(e);
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
+ "&ConnectionId=" + requestId
+ "&Sec-MS-GEC=" + securityToken(Instant.now())
+ "&Sec-MS-GEC-Version=1-" + EDGE_VERSION;
+ "&Sec-MS-GEC-Version=2-" + EDGE_VERSION;
ByteArrayOutputStream audioBuffer = new ByteArrayOutputStream();
CompletableFuture<byte[]> resultFuture = new CompletableFuture<>();
HttpClient client = HttpClient.newBuilder()
try (HttpClient client = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(10))
.build();
.build()) {
WebSocket ws = client.newWebSocketBuilder()
.header("Origin", "chrome-extension://jdiccldimpdaibmpdkjnbmckianbfold")
.connectTimeout(Duration.ofSeconds(10))
.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")
.header("Cookie", "muid=" + UUID.randomUUID().toString().replace("-", "").toUpperCase(Locale.ROOT) + ";")
.buildAsync(URI.create(wsUrl), new WebSocket.Listener() {
private final StringBuilder textBuffer = new StringBuilder();
private final ByteArrayOutputStream binaryBuffer = new ByteArrayOutputStream();
WebSocket ws = client.newWebSocketBuilder()
.header("Origin", "chrome-extension://jdiccldimpdaibmpdkjnbmckianbfold")
.connectTimeout(Duration.ofSeconds(10))
.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")
.header("Cookie", "muid=" + UUID.randomUUID().toString().replace("-", "").toUpperCase(Locale.ROOT) + ";")
.buildAsync(URI.create(wsUrl), new WebSocket.Listener() {
private final StringBuilder textBuffer = new StringBuilder();
private final ByteArrayOutputStream binaryBuffer = new ByteArrayOutputStream();
@Override
public void onOpen(WebSocket webSocket) {
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());
}
@Override
public void onOpen(WebSocket webSocket) {
webSocket.request(Long.MAX_VALUE);
}
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);
@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());
}
} 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);
return null;
}
}).join();
@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;
}
try {
String timestamp = DateTimeFormatter.ofPattern(
"EEE MMM dd yyyy HH:mm:ss 'GMT+0000 (Coordinated Universal Time)'", Locale.US)
.withZone(ZoneOffset.UTC).format(Instant.now());
// 发送配置消息
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();
@Override
public void onError(WebSocket webSocket, Throwable error) {
if (!resultFuture.isDone()) {
resultFuture.completeExceptionally(error);
}
}
}).join();
// 发送 SSML
String escapedText = escapeXml(text);
String ssml = "<speak version='1.0' xmlns='http://www.w3.org/2001/10/synthesis' xml:lang='en-US'>"
+ "<voice name='" + voice + "'>"
+ "<prosody pitch='+0Hz' rate='" + rate + "' volume='+0%'>"
+ escapedText
+ "</prosody></voice></speak>";
try {
String timestamp = DateTimeFormatter.ofPattern(
"EEE MMM dd yyyy HH:mm:ss 'GMT+0000 (Coordinated Universal Time)'", Locale.US)
.withZone(ZoneOffset.UTC).format(Instant.now());
// 发送配置消息
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();
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();
// 发送 SSML
String escapedText = escapeXml(text);
String ssml = "<speak version='1.0' xmlns='http://www.w3.org/2001/10/synthesis' xml:lang='en-US'>"
+ "<voice name='" + voice + "'>"
+ "<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();
// 等待结果(最多 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) throws Exception {
long seconds = now.getEpochSecond() + 11_644_473_600L;
long ticks = (seconds - seconds % 300) * 10_000_000L;
byte[] digest = MessageDigest.getInstance("SHA-256")
.digest((ticks + TRUSTED_CLIENT_TOKEN).getBytes(StandardCharsets.US_ASCII));
return HexFormat.of().withUpperCase().formatHex(digest);
static String securityToken(Instant now) {
return EdgeTtsSecurityToken.generate(now, TRUSTED_CLIENT_TOKEN);
}
static String failureMessage(Throwable error) {
Throwable cause = error;
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) {

View File

@ -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();
}
}

View 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.

View 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.

View File

@ -21,19 +21,7 @@ import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
/**
* 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.
*/
/** Regression coverage for reasoning loops, extended budgets, and tool transitions. */
class NodeStreamingChatHelperThinkingCapTest {
private ChatStreamTracker streamTracker;
@ -112,6 +100,74 @@ class NodeStreamingChatHelperThinkingCapTest {
assertEquals(hugeThinking, result.thinking(),
"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
void reasoningOnlyTokenLimitIsIncompleteRatherThanEmptyOrSuccessful() {
var message = AssistantMessage.builder().content("")

View File

@ -20,14 +20,39 @@ import static org.mockito.Mockito.*;
class EdgeTtsProviderTest {
@Test
void hashesWindowsFileTimeInFiveMinuteBuckets() throws Exception {
assertEquals("7ECB79D14E3AA576D2D79E6D487A1388156D91E614B1BE11C64226A29BC8DD8C",
EdgeTtsProvider.securityToken(java.time.Instant.EPOCH));
assertEquals("492D7694A183A45E-56E821F95D4579C5B7C7E33DC7303FF379EC36D510213555BBBB9EB17EF15876",
EdgeTtsProvider.securityToken(java.time.Instant.ofEpochSecond(1791888000)));
assertEquals(EdgeTtsProvider.securityToken(java.time.Instant.EPOCH),
EdgeTtsProvider.securityToken(java.time.Instant.ofEpochSecond(299)));
assertNotEquals(EdgeTtsProvider.securityToken(java.time.Instant.EPOCH),
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
void usesCurrentHandshakeAndPreservesFragmentedAudio() {
HttpClient.Builder httpBuilder = mock(HttpClient.Builder.class, RETURNS_SELF);
@ -59,11 +84,12 @@ class EdgeTtsProviderTest {
var result = new EdgeTtsProvider().synthesize(
TtsRequest.builder().text("Hello").build(), new SystemSettingsDTO());
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().contains("Sec-MS-GEC-Version=1-"));
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=2-"));
assertTrue(result.isSuccess(), result.getErrorMessage());
assertArrayEquals(new byte[]{1, 2, 3, 4}, result.getAudioData());
verify(socket).abort();
verify(client).close();
}
}
}

View File

@ -3,6 +3,8 @@ import { createI18n } from 'vue-i18n'
import { createPinia } from 'pinia'
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { Message } from '@/types'
import zhCN from '@/i18n/locales/zh-CN'
import enUS from '@/i18n/locales/en-US'
vi.mock('@/composables/useStreamingMarkdown', () => ({
useStreamingMarkdown: (content: unknown) => ({
@ -74,12 +76,16 @@ function mountMessage(message: Message, locale = 'zh-CN') {
messages: {
'zh-CN': {
chat: {
incompleteTitle: zhCN.chat.incompleteTitle,
incompleteDescription: zhCN.chat.incompleteDescription,
stopped: '已被用户手动中止',
interrupted: '已中断并继续处理下一条消息',
},
},
en: {
chat: {
incompleteTitle: enUS.chat.incompleteTitle,
incompleteDescription: enUS.chat.incompleteDescription,
stopped: 'Stopped manually by user',
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()
})
})

View File

@ -699,8 +699,8 @@ export default {
pendingReply: 'Preparing a reply…',
expand: 'Expand',
// INCOMPLETE truncation card (finishReason=incomplete)
incompleteTitle: 'Answer auto-truncated after repeated output was detected',
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.',
incompleteTitle: 'Answer is incomplete',
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',
// EVIDENCE_INSUFFICIENT info card (finishReason=evidence_insufficient)
evidenceTitle: 'Run completed — some source references could not be verified',

View File

@ -699,8 +699,8 @@ export default {
pendingReply: '正在准备回复…',
expand: '展开',
// INCOMPLETE 截断卡片(finishReason=incomplete)
incompleteTitle: '回答因检测到重复输出已被自动截断',
incompleteDescription: '系统在你看到的部分之后检测到模型开始重复同一段内容(或在思考阶段无产出),已自动截断以避免无效输出。点下方按钮重新生成完整回答,或在输入框补充提示让模型聚焦剩余内容。',
incompleteTitle: '回答尚未完成',
incompleteDescription: '本次回答未完成,具体原因请查看消息内容:可能是重复输出触发保护、思考达到安全上限,或模型耗尽输出 token 预算。可重新生成、调整思考和输出预算,或拆分任务后继续。',
incompleteRetry: '重新生成',
// EVIDENCE_INSUFFICIENT 提示卡(finishReason=evidence_insufficient)
evidenceTitle: '任务已完成,但部分源码引用未被验证',