同步平台业务模块能力
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user