feat: Phase 4C infrastructure services + 4D debug commands
4C Services: - RateLimiter: sliding window rate limiting, concurrent semaphore, cooldowns - TokenEstimationService: approximate token counting, cost estimation - NotificationService: cross-platform desktop notifications - InternalLogger: structured session logging with export 4D Debug Commands: - /debug: toggle debug mode, view internal logs - /heapdump: JVM heap dump and memory pool info (Java advantage) - /trace: request/response tracing - /ctx-viz: context window token distribution visualization - /reset-limits: reset rate limits and cooldowns - /sandbox: sandbox mode toggle (strict/permissive/none) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
@@ -0,0 +1,182 @@
|
||||
package com.claudecode.core;
|
||||
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.*;
|
||||
import java.time.Instant;
|
||||
import java.time.LocalDate;
|
||||
import java.time.ZoneId;
|
||||
import java.util.concurrent.ConcurrentLinkedDeque;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
|
||||
/**
|
||||
* 内部日志服务 —— 对应 claude-code 中 internalLogging。
|
||||
* <p>
|
||||
* 功能:
|
||||
* <ul>
|
||||
* <li>结构化会话日志(独立于 SLF4J)</li>
|
||||
* <li>可调试级别(normal/verbose/debug)</li>
|
||||
* <li>导出为文本文件</li>
|
||||
* <li>最近 N 条日志内存缓存(用于 /debug 命令)</li>
|
||||
* <li>日志文件按天分割</li>
|
||||
* </ul>
|
||||
* <p>
|
||||
* 存储位置: ~/.claude-code-java/logs/{date}.log
|
||||
*/
|
||||
public class InternalLogger {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(InternalLogger.class);
|
||||
|
||||
public enum Level { NORMAL, VERBOSE, DEBUG }
|
||||
|
||||
private final Path logDir;
|
||||
private Level currentLevel = Level.NORMAL;
|
||||
private final String sessionId;
|
||||
|
||||
/** 最近日志缓存(用于 /debug 查看) */
|
||||
private final ConcurrentLinkedDeque<LogEntry> recentLogs = new ConcurrentLinkedDeque<>();
|
||||
private static final int MAX_RECENT = 200;
|
||||
|
||||
private final AtomicInteger entryCount = new AtomicInteger(0);
|
||||
|
||||
public InternalLogger(String sessionId) {
|
||||
this(sessionId, Path.of(System.getProperty("user.home"), ".claude-code-java", "logs"));
|
||||
}
|
||||
|
||||
public InternalLogger(String sessionId, Path logDir) {
|
||||
this.sessionId = sessionId;
|
||||
this.logDir = logDir;
|
||||
}
|
||||
|
||||
// ==================== 日志记录 ====================
|
||||
|
||||
public void info(String category, String message) {
|
||||
record(Level.NORMAL, category, message);
|
||||
}
|
||||
|
||||
public void verbose(String category, String message) {
|
||||
record(Level.VERBOSE, category, message);
|
||||
}
|
||||
|
||||
public void debug(String category, String message) {
|
||||
record(Level.DEBUG, category, message);
|
||||
}
|
||||
|
||||
public void toolCall(String toolName, String input, String output, long durationMs) {
|
||||
String msg = String.format("tool=%s duration=%dms input_len=%d output_len=%d",
|
||||
toolName, durationMs,
|
||||
input != null ? input.length() : 0,
|
||||
output != null ? output.length() : 0);
|
||||
record(Level.VERBOSE, "TOOL", msg);
|
||||
}
|
||||
|
||||
public void apiCall(String model, long inputTokens, long outputTokens, long durationMs) {
|
||||
String msg = String.format("model=%s input=%d output=%d duration=%dms",
|
||||
model, inputTokens, outputTokens, durationMs);
|
||||
record(Level.NORMAL, "API", msg);
|
||||
}
|
||||
|
||||
public void error(String category, String message, Throwable throwable) {
|
||||
String msg = throwable != null
|
||||
? message + " — " + throwable.getClass().getSimpleName() + ": " + throwable.getMessage()
|
||||
: message;
|
||||
record(Level.NORMAL, "ERROR:" + category, msg);
|
||||
}
|
||||
|
||||
public void command(String commandName, String args) {
|
||||
record(Level.VERBOSE, "CMD", "/" + commandName + (args != null ? " " + args : ""));
|
||||
}
|
||||
|
||||
public void permission(String toolName, String decision) {
|
||||
record(Level.VERBOSE, "PERM", toolName + " → " + decision);
|
||||
}
|
||||
|
||||
// ==================== 核心记录方法 ====================
|
||||
|
||||
private void record(Level level, String category, String message) {
|
||||
if (level.ordinal() > currentLevel.ordinal()) return;
|
||||
|
||||
LogEntry entry = new LogEntry(
|
||||
Instant.now(), level, category, message, sessionId);
|
||||
|
||||
// 内存缓存
|
||||
recentLogs.addLast(entry);
|
||||
while (recentLogs.size() > MAX_RECENT) {
|
||||
recentLogs.pollFirst();
|
||||
}
|
||||
entryCount.incrementAndGet();
|
||||
|
||||
// 文件写入(异步友好 — 简单同步追加)
|
||||
appendToFile(entry);
|
||||
}
|
||||
|
||||
private void appendToFile(LogEntry entry) {
|
||||
try {
|
||||
Files.createDirectories(logDir);
|
||||
String date = LocalDate.now(ZoneId.systemDefault()).toString();
|
||||
Path file = logDir.resolve(date + ".log");
|
||||
String line = entry.format() + "\n";
|
||||
Files.writeString(file, line, StandardOpenOption.CREATE, StandardOpenOption.APPEND);
|
||||
} catch (IOException e) {
|
||||
// 静默失败 — 不能因为日志写入失败影响主流程
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 查询 ====================
|
||||
|
||||
/**
|
||||
* 获取最近 N 条日志。
|
||||
*/
|
||||
public String getRecent(int count) {
|
||||
StringBuilder sb = new StringBuilder();
|
||||
var entries = recentLogs.stream()
|
||||
.skip(Math.max(0, recentLogs.size() - count))
|
||||
.toList();
|
||||
for (LogEntry entry : entries) {
|
||||
sb.append(entry.format()).append("\n");
|
||||
}
|
||||
return sb.toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* 导出完整日志到文件。
|
||||
*/
|
||||
public Path export(Path targetFile) throws IOException {
|
||||
StringBuilder sb = new StringBuilder();
|
||||
sb.append("# Session Log: ").append(sessionId).append("\n");
|
||||
sb.append("# Exported: ").append(Instant.now()).append("\n\n");
|
||||
for (LogEntry entry : recentLogs) {
|
||||
sb.append(entry.format()).append("\n");
|
||||
}
|
||||
Files.writeString(targetFile, sb.toString());
|
||||
return targetFile;
|
||||
}
|
||||
|
||||
// ==================== 配置 ====================
|
||||
|
||||
public void setLevel(Level level) {
|
||||
this.currentLevel = level;
|
||||
log.info("Internal log level set to {}", level);
|
||||
}
|
||||
|
||||
public Level getLevel() { return currentLevel; }
|
||||
public int getEntryCount() { return entryCount.get(); }
|
||||
public String getSessionId() { return sessionId; }
|
||||
|
||||
// ==================== 日志条目 ====================
|
||||
|
||||
public record LogEntry(
|
||||
Instant timestamp,
|
||||
Level level,
|
||||
String category,
|
||||
String message,
|
||||
String sessionId
|
||||
) {
|
||||
public String format() {
|
||||
String time = timestamp.toString().substring(11, 23); // HH:mm:ss.SSS
|
||||
return String.format("[%s] %s [%s] %s", time, level.name().charAt(0), category, message);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
package com.claudecode.core;
|
||||
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
import java.io.IOException;
|
||||
|
||||
/**
|
||||
* 通知服务 —— 对应 claude-code 中 notifier 服务。
|
||||
* <p>
|
||||
* 提供跨平台桌面通知功能:
|
||||
* <ul>
|
||||
* <li>Windows: PowerShell toast notification</li>
|
||||
* <li>macOS: osascript display notification</li>
|
||||
* <li>Linux: notify-send</li>
|
||||
* <li>Fallback: terminal bell (BEL character)</li>
|
||||
* </ul>
|
||||
* <p>
|
||||
* 可配置:
|
||||
* <ul>
|
||||
* <li>启用/禁用通知</li>
|
||||
* <li>声音提示开关</li>
|
||||
* <li>仅在窗口不活跃时通知</li>
|
||||
* </ul>
|
||||
*/
|
||||
public class NotificationService {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(NotificationService.class);
|
||||
|
||||
private boolean enabled = true;
|
||||
private boolean soundEnabled = true;
|
||||
private boolean onlyWhenInactive = true;
|
||||
|
||||
private final String os;
|
||||
|
||||
public NotificationService() {
|
||||
this.os = System.getProperty("os.name", "").toLowerCase();
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送信息通知。
|
||||
*/
|
||||
public void info(String title, String message) {
|
||||
send(title, message, "info");
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送警告通知。
|
||||
*/
|
||||
public void warning(String title, String message) {
|
||||
send(title, message, "warning");
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送错误通知。
|
||||
*/
|
||||
public void error(String title, String message) {
|
||||
send(title, message, "error");
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送通知。
|
||||
*/
|
||||
public void send(String title, String message, String level) {
|
||||
if (!enabled) return;
|
||||
|
||||
// 播放声音
|
||||
if (soundEnabled) {
|
||||
System.out.print('\u0007'); // BEL
|
||||
System.out.flush();
|
||||
}
|
||||
|
||||
// 发送桌面通知
|
||||
try {
|
||||
if (os.contains("win")) {
|
||||
sendWindows(title, message);
|
||||
} else if (os.contains("mac")) {
|
||||
sendMac(title, message);
|
||||
} else if (os.contains("linux")) {
|
||||
sendLinux(title, message, level);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.debug("Desktop notification failed: {}", e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 任务完成通知。
|
||||
*/
|
||||
public void taskComplete(String taskName) {
|
||||
info("Task Complete", taskName + " has finished");
|
||||
}
|
||||
|
||||
/**
|
||||
* 需要输入通知。
|
||||
*/
|
||||
public void inputRequired(String reason) {
|
||||
warning("Input Required", reason);
|
||||
}
|
||||
|
||||
/**
|
||||
* 错误通知。
|
||||
*/
|
||||
public void errorOccurred(String toolName, String errorMessage) {
|
||||
error("Error in " + toolName, errorMessage);
|
||||
}
|
||||
|
||||
// ==================== 平台通知 ====================
|
||||
|
||||
private void sendWindows(String title, String message) throws IOException, InterruptedException {
|
||||
String ps = String.format(
|
||||
"[System.Reflection.Assembly]::LoadWithPartialName('System.Windows.Forms')|Out-Null;" +
|
||||
"$n=New-Object System.Windows.Forms.NotifyIcon;" +
|
||||
"$n.Icon=[System.Drawing.SystemIcons]::Information;" +
|
||||
"$n.Visible=$true;" +
|
||||
"$n.ShowBalloonTip(3000,'%s','%s','Info');" +
|
||||
"Start-Sleep 1;$n.Dispose()",
|
||||
escape(title), escape(message));
|
||||
new ProcessBuilder("powershell", "-NoProfile", "-Command", ps)
|
||||
.redirectErrorStream(true).start();
|
||||
}
|
||||
|
||||
private void sendMac(String title, String message) throws IOException {
|
||||
String script = String.format(
|
||||
"display notification \"%s\" with title \"%s\"",
|
||||
escape(message), escape(title));
|
||||
new ProcessBuilder("osascript", "-e", script)
|
||||
.redirectErrorStream(true).start();
|
||||
}
|
||||
|
||||
private void sendLinux(String title, String message, String level) throws IOException {
|
||||
String urgency = switch (level) {
|
||||
case "error" -> "critical";
|
||||
case "warning" -> "normal";
|
||||
default -> "low";
|
||||
};
|
||||
new ProcessBuilder("notify-send", "-u", urgency, title, message)
|
||||
.redirectErrorStream(true).start();
|
||||
}
|
||||
|
||||
private String escape(String s) {
|
||||
return s.replace("'", "''").replace("\"", "\\\"");
|
||||
}
|
||||
|
||||
// ==================== 配置 ====================
|
||||
|
||||
public void setEnabled(boolean enabled) { this.enabled = enabled; }
|
||||
public boolean isEnabled() { return enabled; }
|
||||
|
||||
public void setSoundEnabled(boolean enabled) { this.soundEnabled = enabled; }
|
||||
public boolean isSoundEnabled() { return soundEnabled; }
|
||||
|
||||
public void setOnlyWhenInactive(boolean only) { this.onlyWhenInactive = only; }
|
||||
public boolean isOnlyWhenInactive() { return onlyWhenInactive; }
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
package com.claudecode.core;
|
||||
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.concurrent.Semaphore;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
|
||||
/**
|
||||
* 速率限制器 —— 对应 claude-code 中 claudeAiLimits / policyLimits。
|
||||
* <p>
|
||||
* 本地实现(无远程策略服务),支持:
|
||||
* <ul>
|
||||
* <li>滑动窗口请求频率限制</li>
|
||||
* <li>并发工具执行限制</li>
|
||||
* <li>按键(API/tool/command)独立限流</li>
|
||||
* <li>冷却时间和指数退避</li>
|
||||
* </ul>
|
||||
*/
|
||||
public class RateLimiter {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(RateLimiter.class);
|
||||
|
||||
/** 滑动窗口时间帧 */
|
||||
private final Duration windowDuration;
|
||||
|
||||
/** 窗口内最大请求数 */
|
||||
private final int maxRequestsPerWindow;
|
||||
|
||||
/** 最大并发执行数 */
|
||||
private final int maxConcurrent;
|
||||
|
||||
/** 并发信号量 */
|
||||
private final Semaphore concurrentSemaphore;
|
||||
|
||||
/** 每个 key 的请求时间戳记录 */
|
||||
private final ConcurrentHashMap<String, SlidingWindow> windows = new ConcurrentHashMap<>();
|
||||
|
||||
/** 冷却中的 key */
|
||||
private final ConcurrentHashMap<String, Instant> cooldowns = new ConcurrentHashMap<>();
|
||||
|
||||
/** 全局请求计数(统计用) */
|
||||
private final AtomicInteger totalRequests = new AtomicInteger(0);
|
||||
private final AtomicInteger totalRejections = new AtomicInteger(0);
|
||||
|
||||
/**
|
||||
* 创建速率限制器(默认配置)。
|
||||
*/
|
||||
public RateLimiter() {
|
||||
this(60, Duration.ofMinutes(1), 5);
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建速率限制器。
|
||||
*
|
||||
* @param maxRequestsPerWindow 窗口内最大请求数
|
||||
* @param windowDuration 滑动窗口时长
|
||||
* @param maxConcurrent 最大并发执行数
|
||||
*/
|
||||
public RateLimiter(int maxRequestsPerWindow, Duration windowDuration, int maxConcurrent) {
|
||||
this.maxRequestsPerWindow = maxRequestsPerWindow;
|
||||
this.windowDuration = windowDuration;
|
||||
this.maxConcurrent = maxConcurrent;
|
||||
this.concurrentSemaphore = new Semaphore(maxConcurrent);
|
||||
}
|
||||
|
||||
/**
|
||||
* 尝试获取执行许可(非阻塞)。
|
||||
*
|
||||
* @param key 限流键(如 "api", "tool:bash", "command:commit")
|
||||
* @return 是否获得许可
|
||||
*/
|
||||
public boolean tryAcquire(String key) {
|
||||
totalRequests.incrementAndGet();
|
||||
|
||||
// 检查冷却
|
||||
Instant cooldownEnd = cooldowns.get(key);
|
||||
if (cooldownEnd != null && Instant.now().isBefore(cooldownEnd)) {
|
||||
totalRejections.incrementAndGet();
|
||||
log.debug("Rate limited (cooldown): {}", key);
|
||||
return false;
|
||||
}
|
||||
cooldowns.remove(key);
|
||||
|
||||
// 检查滑动窗口
|
||||
SlidingWindow window = windows.computeIfAbsent(key,
|
||||
k -> new SlidingWindow(maxRequestsPerWindow, windowDuration));
|
||||
if (!window.tryAcquire()) {
|
||||
totalRejections.incrementAndGet();
|
||||
log.debug("Rate limited (window): {} ({}/{})", key, window.getCount(), maxRequestsPerWindow);
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取并发执行许可(阻塞,带超时)。
|
||||
*
|
||||
* @param timeoutMs 超时毫秒数
|
||||
* @return 是否获得许可
|
||||
*/
|
||||
public boolean acquireConcurrent(long timeoutMs) {
|
||||
try {
|
||||
return concurrentSemaphore.tryAcquire(timeoutMs, TimeUnit.MILLISECONDS);
|
||||
} catch (InterruptedException e) {
|
||||
Thread.currentThread().interrupt();
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 释放并发执行许可。
|
||||
*/
|
||||
public void releaseConcurrent() {
|
||||
concurrentSemaphore.release();
|
||||
}
|
||||
|
||||
/**
|
||||
* 为指定 key 设置冷却时间。
|
||||
*/
|
||||
public void setCooldown(String key, Duration cooldownDuration) {
|
||||
cooldowns.put(key, Instant.now().plus(cooldownDuration));
|
||||
log.debug("Cooldown set for {}: {}", key, cooldownDuration);
|
||||
}
|
||||
|
||||
/**
|
||||
* 重置指定 key 的限制。
|
||||
*/
|
||||
public void reset(String key) {
|
||||
windows.remove(key);
|
||||
cooldowns.remove(key);
|
||||
}
|
||||
|
||||
/**
|
||||
* 重置所有限制。
|
||||
*/
|
||||
public void resetAll() {
|
||||
windows.clear();
|
||||
cooldowns.clear();
|
||||
totalRequests.set(0);
|
||||
totalRejections.set(0);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取剩余可用请求数。
|
||||
*/
|
||||
public int getRemaining(String key) {
|
||||
SlidingWindow window = windows.get(key);
|
||||
if (window == null) return maxRequestsPerWindow;
|
||||
return Math.max(0, maxRequestsPerWindow - window.getCount());
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取冷却剩余时间。
|
||||
*/
|
||||
public Duration getCooldownRemaining(String key) {
|
||||
Instant end = cooldowns.get(key);
|
||||
if (end == null) return Duration.ZERO;
|
||||
Duration remaining = Duration.between(Instant.now(), end);
|
||||
return remaining.isNegative() ? Duration.ZERO : remaining;
|
||||
}
|
||||
|
||||
public int getTotalRequests() { return totalRequests.get(); }
|
||||
public int getTotalRejections() { return totalRejections.get(); }
|
||||
public int getAvailableConcurrent() { return concurrentSemaphore.availablePermits(); }
|
||||
|
||||
/**
|
||||
* 状态报告。
|
||||
*/
|
||||
public String statusReport() {
|
||||
return String.format("RateLimiter: %d/%d requests, %d rejections, %d/%d concurrent slots",
|
||||
totalRequests.get(), maxRequestsPerWindow,
|
||||
totalRejections.get(),
|
||||
maxConcurrent - concurrentSemaphore.availablePermits(), maxConcurrent);
|
||||
}
|
||||
|
||||
// ==================== 滑动窗口实现 ====================
|
||||
|
||||
private static class SlidingWindow {
|
||||
private final int maxRequests;
|
||||
private final Duration windowDuration;
|
||||
private final long[] timestamps;
|
||||
private int head = 0;
|
||||
private int count = 0;
|
||||
|
||||
SlidingWindow(int maxRequests, Duration windowDuration) {
|
||||
this.maxRequests = maxRequests;
|
||||
this.windowDuration = windowDuration;
|
||||
this.timestamps = new long[maxRequests + 1];
|
||||
}
|
||||
|
||||
synchronized boolean tryAcquire() {
|
||||
evict();
|
||||
if (count >= maxRequests) return false;
|
||||
timestamps[(head + count) % timestamps.length] = System.currentTimeMillis();
|
||||
count++;
|
||||
return true;
|
||||
}
|
||||
|
||||
synchronized int getCount() {
|
||||
evict();
|
||||
return count;
|
||||
}
|
||||
|
||||
private void evict() {
|
||||
long cutoff = System.currentTimeMillis() - windowDuration.toMillis();
|
||||
while (count > 0 && timestamps[head] < cutoff) {
|
||||
head = (head + 1) % timestamps.length;
|
||||
count--;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
package com.claudecode.core;
|
||||
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
/**
|
||||
* Token 估算服务 —— 对应 claude-code 中 tokenEstimation。
|
||||
* <p>
|
||||
* 使用简化的 cl100k_base / o200k_base 编码近似估算 token 数量。
|
||||
* 不使用真正的 BPE 编码器(避免大型词表依赖),而是基于统计规律近似。
|
||||
* <p>
|
||||
* 近似规则:
|
||||
* <ul>
|
||||
* <li>英文文本:~4 chars/token</li>
|
||||
* <li>代码:~3.5 chars/token</li>
|
||||
* <li>中文/日文/韩文:~1.5 chars/token</li>
|
||||
* <li>JSON/结构化数据:~3 chars/token</li>
|
||||
* </ul>
|
||||
*/
|
||||
public class TokenEstimationService {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(TokenEstimationService.class);
|
||||
|
||||
private static final Pattern CJK_PATTERN = Pattern.compile("[\\u4e00-\\u9fff\\u3040-\\u30ff\\uac00-\\ud7a3]");
|
||||
private static final Pattern CODE_PATTERN = Pattern.compile("[{}()\\[\\];=<>|&!+\\-*/^~]");
|
||||
private static final Pattern WHITESPACE_PATTERN = Pattern.compile("\\s+");
|
||||
|
||||
/** 每 token 的平均字符数(各类型) */
|
||||
private static final double CHARS_PER_TOKEN_ENGLISH = 4.0;
|
||||
private static final double CHARS_PER_TOKEN_CODE = 3.5;
|
||||
private static final double CHARS_PER_TOKEN_CJK = 1.5;
|
||||
private static final double CHARS_PER_TOKEN_JSON = 3.0;
|
||||
|
||||
/**
|
||||
* 估算文本的 token 数量。
|
||||
*/
|
||||
public int estimateTokens(String text) {
|
||||
if (text == null || text.isEmpty()) return 0;
|
||||
|
||||
int totalChars = text.length();
|
||||
if (totalChars == 0) return 0;
|
||||
|
||||
// 分析文本组成
|
||||
int cjkChars = countCJK(text);
|
||||
int codeChars = countCodeChars(text);
|
||||
int remainingChars = totalChars - cjkChars - codeChars;
|
||||
|
||||
// 按类型估算
|
||||
double cjkTokens = cjkChars / CHARS_PER_TOKEN_CJK;
|
||||
double codeTokens = codeChars / CHARS_PER_TOKEN_CODE;
|
||||
double textTokens = remainingChars / CHARS_PER_TOKEN_ENGLISH;
|
||||
|
||||
// 考虑 JSON 结构
|
||||
if (looksLikeJson(text)) {
|
||||
return (int) Math.ceil(totalChars / CHARS_PER_TOKEN_JSON);
|
||||
}
|
||||
|
||||
int estimated = (int) Math.ceil(cjkTokens + codeTokens + textTokens);
|
||||
|
||||
// 每段文本至少 1 个 token
|
||||
return Math.max(1, estimated);
|
||||
}
|
||||
|
||||
/**
|
||||
* 估算消息列表的总 token 数(包括消息结构开销)。
|
||||
*/
|
||||
public int estimateMessageTokens(String role, String content) {
|
||||
// 每条消息有 ~4 token 的结构开销(role 标记、分隔符等)
|
||||
int contentTokens = estimateTokens(content);
|
||||
return contentTokens + 4;
|
||||
}
|
||||
|
||||
/**
|
||||
* 估算系统提示词 token 数。
|
||||
*/
|
||||
public int estimateSystemPromptTokens(String systemPrompt) {
|
||||
// 系统提示词通常有额外的缓存标记开销
|
||||
return estimateTokens(systemPrompt) + 10;
|
||||
}
|
||||
|
||||
/**
|
||||
* 估算工具定义的 token 数。
|
||||
*/
|
||||
public int estimateToolDefinitionTokens(String toolName, String description, String inputSchema) {
|
||||
int tokens = estimateTokens(toolName) + estimateTokens(description);
|
||||
if (inputSchema != null) {
|
||||
tokens += estimateTokens(inputSchema);
|
||||
}
|
||||
// 工具定义的结构开销
|
||||
tokens += 20;
|
||||
return tokens;
|
||||
}
|
||||
|
||||
/**
|
||||
* 将 token 数转换为近似费用。
|
||||
*
|
||||
* @param inputTokens 输入 token 数
|
||||
* @param outputTokens 输出 token 数
|
||||
* @param model 模型名称
|
||||
* @return 估计费用(美元)
|
||||
*/
|
||||
public double estimateCost(long inputTokens, long outputTokens, String model) {
|
||||
double inputRate;
|
||||
double outputRate;
|
||||
|
||||
if (model != null && model.toLowerCase().contains("opus")) {
|
||||
inputRate = 15.0; // $15/M
|
||||
outputRate = 75.0; // $75/M
|
||||
} else if (model != null && model.toLowerCase().contains("haiku")) {
|
||||
inputRate = 0.25; // $0.25/M
|
||||
outputRate = 1.25; // $1.25/M
|
||||
} else {
|
||||
// Default: Sonnet pricing
|
||||
inputRate = 3.0; // $3/M
|
||||
outputRate = 15.0; // $15/M
|
||||
}
|
||||
|
||||
return (inputTokens / 1_000_000.0 * inputRate)
|
||||
+ (outputTokens / 1_000_000.0 * outputRate);
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化 token 数量。
|
||||
*/
|
||||
public String formatTokenCount(int tokens) {
|
||||
if (tokens >= 1_000_000) return String.format("%.1fM", tokens / 1_000_000.0);
|
||||
if (tokens >= 1_000) return String.format("%.1fK", tokens / 1_000.0);
|
||||
return String.valueOf(tokens);
|
||||
}
|
||||
|
||||
// ==================== 内部方法 ====================
|
||||
|
||||
private int countCJK(String text) {
|
||||
int count = 0;
|
||||
for (int i = 0; i < text.length(); i++) {
|
||||
char c = text.charAt(i);
|
||||
if ((c >= '\u4e00' && c <= '\u9fff') // CJK Unified
|
||||
|| (c >= '\u3040' && c <= '\u30ff') // Hiragana + Katakana
|
||||
|| (c >= '\uac00' && c <= '\ud7a3')) { // Hangul
|
||||
count++;
|
||||
}
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
private int countCodeChars(String text) {
|
||||
int count = 0;
|
||||
for (int i = 0; i < text.length(); i++) {
|
||||
char c = text.charAt(i);
|
||||
if ("{}()[];=<>|&!+-*/^~@#$%".indexOf(c) >= 0) {
|
||||
count++;
|
||||
}
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
private boolean looksLikeJson(String text) {
|
||||
String trimmed = text.trim();
|
||||
return (trimmed.startsWith("{") && trimmed.endsWith("}"))
|
||||
|| (trimmed.startsWith("[") && trimmed.endsWith("]"));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user