同步平台业务模块能力

This commit is contained in:
baiyanyun
2026-07-13 09:13:30 +08:00
parent e612f5e434
commit a1a38f62d8
224 changed files with 22331 additions and 1224 deletions

View File

@@ -75,7 +75,7 @@ public class ModelApiProxyConfigServiceImpl implements IModelApiProxyConfigServi
private String enableModelProxy;
@Override
public BackendModelDto getBackendModelConfig(String userApiKey, Long id) {
public BackendModelDto getBackendModelConfig(String userApiKey, Long id, String traceId) {
String cacheKey = BACKEND_MODEL_KEY_PREFIX + userApiKey + (id == null ? "-" : id);
Object val = redisUtil.get(cacheKey);
if (val != null) {
@@ -118,7 +118,7 @@ public class ModelApiProxyConfigServiceImpl implements IModelApiProxyConfigServi
userAccessKeyDto.getConfig().setEnabled(modelInfoDto.getEnabled() != null && modelInfoDto.getEnabled() == 1);
userAccessKeyDto.getConfig().setUserName("user" + userAccessKeyDto.getUserId());
userAccessKeyDto.getConfig().setConversationId(userAccessKeyDto.getUserId().toString());
userAccessKeyDto.getConfig().setRequestId(UUID.randomUUID().toString().replace("-", ""));
userAccessKeyDto.getConfig().setRequestId(traceId == null ? UUID.randomUUID().toString().replace("-", "") : traceId);
userAccessKeyDto.getConfig().setModelBaseUrl(apiInfo.getUrl());
userAccessKeyDto.getConfig().setTraceContext(TraceContext.builder()
.nickName(userAccessKeyDto.getConfig().getUserName())
@@ -129,6 +129,7 @@ public class ModelApiProxyConfigServiceImpl implements IModelApiProxyConfigServi
.billUserId(userAccessKeyDto.getUserId())
.enableSubscription(tenantConfigDto.getEnableSubscription() != null && tenantConfigDto.getEnableSubscription() == 1)
.traceId(userAccessKeyDto.getConfig().getRequestId())
.apiKey(TraceContext.maskApiKey(userApiKey))
.traceTargets(List.of(TraceContext.TraceTarget.builder()
.targetId(modelInfoDto.getId().toString())
.targetType(TraceContext.TraceTargetType.Model)

View File

@@ -35,6 +35,7 @@ public class BackendProxyHandler extends ChannelInboundHandlerAdapter {
private volatile Map<String, Object> responseContext;
private final TokenLogService tokenLogService;
private volatile boolean logPushed = false;
private String contentEncoding = null;
public BackendProxyHandler(TokenLogService tokenLogService) {
this.tokenLogService = tokenLogService;
@@ -108,6 +109,7 @@ public class BackendProxyHandler extends ChannelInboundHandlerAdapter {
sb.append(header.getKey()).append(": ").append(header.getValue()).append("\n");
logger.debug(" {}: {}", header.getKey(), header.getValue());
});
contentEncoding = response.headers().get("Content-Encoding");
getResponseContext().put("responseHeaders", sb.toString());
} catch (Exception e) {
logger.error("Error logging response header", e);
@@ -148,7 +150,9 @@ public class BackendProxyHandler extends ChannelInboundHandlerAdapter {
logger.debug("[Model Proxy Response Body] AccessKey: {}, ResponseTime: {}ms", accessKey, responseTime);
logger.debug("Backend Model: {}", backendModel);
// 统一将字节转换为字符串确保UTF-8编码正确
String resBody = responseBuffer.toString(CharsetUtil.UTF_8);
byte[] byteArray = responseBuffer.toByteArray();
byte[] decompress = DecompressUtils.decompress(byteArray, contentEncoding);
String resBody = new String(decompress, CharsetUtil.UTF_8);
logger.debug("Response Body: {}", resBody);
getResponseContext().put("responseBody", resBody);

View File

@@ -0,0 +1,72 @@
package com.xspaceagi.modelproxy.infra.proxy;
import lombok.extern.slf4j.Slf4j;
import org.brotli.dec.BrotliInputStream;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.InputStream;
import java.util.zip.GZIPInputStream;
@Slf4j
public class DecompressUtils {
/**
* 根据 Content-Encoding 自动解压
*
* @param data 原始字节
* @param encoding Content-Encoding 值gzip, br, deflate, null
*/
public static byte[] decompress(byte[] data, String encoding) throws Exception {
try {
if (encoding == null || encoding.isEmpty()) {
return data;
}
return switch (encoding.toLowerCase()) {
case "gzip", "x-gzip" -> decompressGzip(data);
case "br" -> decompressBrotli(data);
case "deflate" -> decompressDeflate(data);
default -> data;
};
} catch (Throwable e) {
log.error("Error decompressing data", e);
return data;
}
}
private static byte[] decompressGzip(byte[] compressed) throws Exception {
try (InputStream is = new GZIPInputStream(new ByteArrayInputStream(compressed));
ByteArrayOutputStream bos = new ByteArrayOutputStream()) {
byte[] buffer = new byte[4096];
int len;
while ((len = is.read(buffer)) != -1) {
bos.write(buffer, 0, len);
}
return bos.toByteArray();
}
}
private static byte[] decompressBrotli(byte[] compressed) throws Exception {
try (InputStream bis = new BrotliInputStream(new ByteArrayInputStream(compressed));
ByteArrayOutputStream bos = new ByteArrayOutputStream()) {
byte[] buffer = new byte[4096];
int len;
while ((len = bis.read(buffer)) != -1) {
bos.write(buffer, 0, len);
}
return bos.toByteArray();
}
}
private static byte[] decompressDeflate(byte[] compressed) throws Exception {
try (InputStream is = new java.util.zip.InflaterInputStream(new ByteArrayInputStream(compressed));
ByteArrayOutputStream bos = new ByteArrayOutputStream()) {
byte[] buffer = new byte[4096];
int len;
while ((len = is.read(buffer)) != -1) {
bos.write(buffer, 0, len);
}
return bos.toByteArray();
}
}
}

View File

@@ -87,6 +87,7 @@ public class HttpProxyRequestHandler extends ChannelInboundHandlerAdapter {
int targetPort;
String targetScheme;
logger.debug("uri {}, headers {}", request.uri(), request.headers());
String traceId = request.headers().get("trace-id");
//从header里通过x-api-key或Authorization获取apiKey
String apiKey = request.headers().get("x-api-key");
String xApiKey = apiKey;
@@ -110,7 +111,7 @@ public class HttpProxyRequestHandler extends ChannelInboundHandlerAdapter {
return;
}
backendModelDto = modelApiProxyConfigService.getBackendModelConfig(apiKey, extractModelId(request.uri()));
backendModelDto = modelApiProxyConfigService.getBackendModelConfig(apiKey, extractModelId(request.uri()), traceId);
if (backendModelDto == null) {
sendError(ctx, "Invalid API Key", msg, HttpResponseStatus.FORBIDDEN);
return;
@@ -362,6 +363,10 @@ public class HttpProxyRequestHandler extends ChannelInboundHandlerAdapter {
} catch (Exception ignored) {
}
}
Object pending;
while ((pending = receivedLastMsgsWhenConnect.poll()) != null) {
ReferenceCountUtil.release(pending);
}
super.channelInactive(ctx);
}
@@ -369,6 +374,7 @@ public class HttpProxyRequestHandler extends ChannelInboundHandlerAdapter {
public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) throws Exception {
if (cause.getCause() != null && (cause.getCause() instanceof SSLHandshakeException)) {
logger.warn("SSLHandshakeException: {}", cause.getCause().getMessage());
ctx.close();
return;
}
super.exceptionCaught(ctx, cause);

View File

@@ -87,10 +87,12 @@ public class TokenLogService implements TaskExecuteService {
long cacheInputTokens = 0L;
long inputTokens = 0L;
long outputTokens = 0L;
long cacheCreationInputTokens = 0L;
String content = null;
if ("Anthropic".equalsIgnoreCase(apiProtocol)) {
AnthropicSSEParser.TokenUsage tokenUsage = AnthropicSSEParser.extractTokenUsage(responseBody);
inputTokens = tokenUsage.inputTokens;
cacheCreationInputTokens = tokenUsage.cacheCreationInputTokens;
inputTokens = (long) (tokenUsage.inputTokens + (tokenUsage.cacheCreationInputTokens * 1.25));//一般厂商缓存创建输入的价格比正常输入的要贵25%
outputTokens = tokenUsage.outputTokens;
cacheInputTokens = tokenUsage.cacheInputTokens;
content = tokenUsage.text;
@@ -105,7 +107,8 @@ public class TokenLogService implements TaskExecuteService {
} else if ("OpenAi".equalsIgnoreCase(apiProtocol)) {
OpenAISSEParser.TokenUsage tokenUsage = OpenAISSEParser.extractTokenUsage(responseBody);
cacheInputTokens = tokenUsage.cachedTokens;
inputTokens = tokenUsage.promptTokens;
cacheCreationInputTokens = tokenUsage.cacheCreationInputTokens;
inputTokens = (long) (tokenUsage.promptTokens - cacheInputTokens + tokenUsage.cacheCreationInputTokens * 1.25);
outputTokens = tokenUsage.completionTokens;
content = tokenUsage.content;
}
@@ -143,8 +146,13 @@ public class TokenLogService implements TaskExecuteService {
.conversationId(conversationId)
.targetName(model)
.cacheInputToken((int) cacheInputTokens)
.cacheCreationInputToken((int) cacheCreationInputTokens)
.inputToken((int) inputTokens)
.outputToken((int) outputTokens)
.apiKey(traceContext != null && traceContext.getApiKey() != null ? TraceContext.maskApiKey(traceContext.getApiKey()) : "")
.resultCode(traceContext != null && traceContext.isError() ? traceContext.getErrorCode() : "0000")
.resultMsg(traceContext != null && traceContext.isError() ? traceContext.getErrorMessage() : "success")
.apiKey(traceContext != null && traceContext.getApiKey() != null ? traceContext.getApiKey() : "")
.build());
}
if (traceContext != null) {

View File

@@ -8,7 +8,7 @@ import com.xspaceagi.modelproxy.sdk.service.dto.FrontendModelDto;
*/
public interface IModelApiProxyConfigService {
BackendModelDto getBackendModelConfig(String userApiKey, Long id);
BackendModelDto getBackendModelConfig(String userApiKey, Long id, String traceId);
FrontendModelDto generateUserFrontendModelConfig(Long tenantId, Long userId, Long agentId, BackendModelDto backendModel, String siteUrl);
}

View File

@@ -1,11 +1,5 @@
package com.xspaceagi.modelproxy.spec.utils;
import com.alibaba.fastjson2.JSON;
import com.alibaba.fastjson2.JSONObject;
import java.time.LocalDateTime;
import java.time.ZoneId;
import java.time.format.DateTimeFormatter;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
@@ -20,76 +14,46 @@ public class AnthropicSSEParser {
private static final Pattern TEXT_DELTA_PATTERN = Pattern.compile("\"type\"\\s*:\\s*\"text_delta\"");
private static final Pattern TEXT_PATTERN = Pattern.compile("\"text\"\\s*:\\s*\"([^\"]*)\"");
public static TokenUsage extractTokenUsage(String sseData) {
public static TokenUsage extractTokenUsage(String data) {
TokenUsage usage = new TokenUsage();
StringBuilder textBuilder = new StringBuilder();
String[] lines = sseData.split("\n");
for (String line : lines) {
line = line.trim();
if (line.startsWith("data:")) {
String data = line.substring(5).trim();
if (data.equals("[DONE]")) {
continue;
}
Matcher caheInputMatcher = CACHE_INPUT_TOKENS_PATTERN.matcher(data);
if (caheInputMatcher.find()) {
usage.cacheInputTokens = Integer.parseInt(caheInputMatcher.group(1));
}
Matcher inputMatcher = INPUT_TOKENS_PATTERN.matcher(data);
if (inputMatcher.find()) {
usage.inputTokens = Integer.parseInt(inputMatcher.group(1));
}
Matcher promptCacheCreateMatcher = CACHE_PROMPT_CREATE_TOKENS_PATTERN.matcher(data);
if (promptCacheCreateMatcher.find()) {
usage.inputTokens += Integer.parseInt(promptCacheCreateMatcher.group(1));
}
Matcher outputMatcher = OUTPUT_TOKENS_PATTERN.matcher(data);
if (outputMatcher.find()) {
usage.outputTokens = Integer.parseInt(outputMatcher.group(1));
}
if (TEXT_DELTA_PATTERN.matcher(data).find()) {
usage.outputTokensDelta++;
Matcher textMatcher = TEXT_PATTERN.matcher(data);
if (textMatcher.find()) {
textBuilder.append(textMatcher.group(1));
}
}
}
Matcher caheInputMatcher = CACHE_INPUT_TOKENS_PATTERN.matcher(data);
while (caheInputMatcher.find()) {
usage.cacheInputTokens = Integer.parseInt(caheInputMatcher.group(1));
}
Matcher inputMatcher = INPUT_TOKENS_PATTERN.matcher(data);
while (inputMatcher.find()) {
// 取最后一个为计费数量
usage.inputTokens = Integer.parseInt(inputMatcher.group(1));
}
Matcher promptCacheCreateMatcher = CACHE_PROMPT_CREATE_TOKENS_PATTERN.matcher(data);
while (promptCacheCreateMatcher.find()) {
usage.cacheCreationInputTokens = Integer.parseInt(promptCacheCreateMatcher.group(1));
}
Matcher outputMatcher = OUTPUT_TOKENS_PATTERN.matcher(data);
while (outputMatcher.find()) {
usage.outputTokens = Integer.parseInt(outputMatcher.group(1));
}
if (TEXT_DELTA_PATTERN.matcher(data).find()) {
usage.outputTokensDelta++;
Matcher textMatcher = TEXT_PATTERN.matcher(data);
while (textMatcher.find()) {
textBuilder.append(textMatcher.group(1));
}
}
usage.text = textBuilder.toString();
if (JSON.isValidObject(sseData)) {
JSONObject jsonObject = JSON.parseObject(sseData);
JSONObject error = jsonObject.getJSONObject("error");
if (error != null) {
// 仅针对智谱模型
usage.message = error.getString("message");
long timestamp = extractResetTimestamp(sseData);
if (timestamp > 0) {
usage.stopTimeSeconds = timestamp - System.currentTimeMillis() / 1000;
usage.stopAccount = true;
}
if ("1302".equals(error.getString("code"))) {
usage.stopAccount = true;
usage.stopTimeSeconds = 60;
}
}
if (usage.outputTokens == 0L) {
usage.inputTokens = 0L;// 避免统计token时算价
}
return usage;
}
public static class TokenUsage {
public long cacheCreationInputTokens = 0;
public long cacheInputTokens = 0;
public long inputTokens = 0;
public long outputTokens = 0;
@@ -105,28 +69,4 @@ public class AnthropicSSEParser {
inputTokens, outputTokens, outputTokensDelta, text);
}
}
/**
* 从 Anthropic 错误响应中提取限额重置时间戳[智谱编程套餐]
*
* @param response 错误响应 JSON 字符串
* @return 重置时间戳(秒),如果未找到则返回 -1
*/
public static long extractResetTimestamp(String response) {
if (response == null || response.isEmpty()) {
return -1;
}
Matcher matcher = Pattern.compile("您的限额将在\\s+(\\d{4}-\\d{2}-\\d{2}\\s+\\d{2}:\\d{2}:\\d{2})")
.matcher(response);
if (matcher.find()) {
return LocalDateTime.parse(matcher.group(1),
DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"))
.atZone(ZoneId.systemDefault())
.toEpochSecond();
}
return -1;
}
}

View File

@@ -12,54 +12,39 @@ public class OpenAISSEParser {
private static final Pattern TOTAL_TOKENS_PATTERN = Pattern.compile("\"total_tokens\"\\s*:\\s*(\\d+)");
private static final Pattern CONTENT_PATTERN = Pattern.compile("\"content\"\\s*:\\s*\"([^\"]*)\"");
public static TokenUsage extractTokenUsage(String sseData) {
public static TokenUsage extractTokenUsage(String data) {
TokenUsage usage = new TokenUsage();
StringBuilder textBuilder = new StringBuilder();
Matcher cachedMatcher = CAHED_TOKENS_PATTERN.matcher(data);
while (cachedMatcher.find()) {
usage.cachedTokens = Integer.parseInt(cachedMatcher.group(1));
}
String[] lines = sseData.split("\n");
Matcher promptMatcher = PROMPT_TOKENS_PATTERN.matcher(data);
while (promptMatcher.find()) {
usage.promptTokens = Integer.parseInt(promptMatcher.group(1));
}
for (String line : lines) {
line = line.trim();
Matcher promptCacheCreateMatcher = CACHE_PROMPT_CREATE_TOKENS_PATTERN.matcher(data);
while (promptCacheCreateMatcher.find()) {
usage.cacheCreationInputTokens = Integer.parseInt(promptCacheCreateMatcher.group(1));
}
if (line.startsWith("data:")) {
String data = line.substring(5).trim();
Matcher completionMatcher = COMPLETION_TOKENS_PATTERN.matcher(data);
while (completionMatcher.find()) {
usage.completionTokens = Integer.parseInt(completionMatcher.group(1));
}
if (data.equals("[DONE]")) {
continue;
}
Matcher totalMatcher = TOTAL_TOKENS_PATTERN.matcher(data);
while (totalMatcher.find()) {
usage.totalTokens = Integer.parseInt(totalMatcher.group(1));
}
Matcher cachedMatcher = CAHED_TOKENS_PATTERN.matcher(data);
if (cachedMatcher.find()) {
usage.cachedTokens = Integer.parseInt(cachedMatcher.group(1));
}
Matcher promptMatcher = PROMPT_TOKENS_PATTERN.matcher(data);
if (promptMatcher.find()) {
usage.promptTokens = Integer.parseInt(promptMatcher.group(1));
}
Matcher promptCacheCreateMatcher = CACHE_PROMPT_CREATE_TOKENS_PATTERN.matcher(data);
if (promptCacheCreateMatcher.find()) {
usage.promptTokens += Integer.parseInt(promptCacheCreateMatcher.group(1));
}
Matcher completionMatcher = COMPLETION_TOKENS_PATTERN.matcher(data);
if (completionMatcher.find()) {
usage.completionTokens = Integer.parseInt(completionMatcher.group(1));
}
Matcher totalMatcher = TOTAL_TOKENS_PATTERN.matcher(data);
if (totalMatcher.find()) {
usage.totalTokens = Integer.parseInt(totalMatcher.group(1));
}
Matcher contentMatcher = CONTENT_PATTERN.matcher(data);
while (contentMatcher.find()) {
String content = contentMatcher.group(1);
textBuilder.append(content);
usage.contentDeltaCount++;
}
}
Matcher contentMatcher = CONTENT_PATTERN.matcher(data);
while (contentMatcher.find()) {
String content = contentMatcher.group(1);
textBuilder.append(content);
usage.contentDeltaCount++;
}
usage.content = textBuilder.toString();
@@ -67,6 +52,7 @@ public class OpenAISSEParser {
}
public static class TokenUsage {
public long cacheCreationInputTokens = 0;
public long cachedTokens = 0;
public long promptTokens = 0;
public long completionTokens = 0;