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>pull/1/head
parent
d09809e924
commit
dd47566cb8
@ -0,0 +1,107 @@ |
|||||||
|
package com.claudecode.command.impl; |
||||||
|
|
||||||
|
import com.claudecode.command.CommandContext; |
||||||
|
import com.claudecode.command.SlashCommand; |
||||||
|
import com.claudecode.console.AnsiStyle; |
||||||
|
import com.claudecode.core.InternalLogger; |
||||||
|
|
||||||
|
import java.util.List; |
||||||
|
|
||||||
|
/** |
||||||
|
* /debug 命令 —— 调试模式开关 + 工具调用追踪。 |
||||||
|
*/ |
||||||
|
public class DebugCommand implements SlashCommand { |
||||||
|
|
||||||
|
@Override |
||||||
|
public String name() { return "debug"; } |
||||||
|
|
||||||
|
@Override |
||||||
|
public String description() { return "Toggle debug mode and view internal logs"; } |
||||||
|
|
||||||
|
@Override |
||||||
|
public List<String> aliases() { return List.of("dbg"); } |
||||||
|
|
||||||
|
@Override |
||||||
|
public String execute(String args, CommandContext context) { |
||||||
|
String trimmed = (args == null) ? "" : args.trim(); |
||||||
|
|
||||||
|
StringBuilder sb = new StringBuilder(); |
||||||
|
sb.append("\n").append(AnsiStyle.bold(" 🐛 Debug Mode\n")); |
||||||
|
sb.append(" ").append("─".repeat(40)).append("\n\n"); |
||||||
|
|
||||||
|
if (context.agentLoop() == null) { |
||||||
|
return sb.append(" No active agent loop\n").toString(); |
||||||
|
} |
||||||
|
|
||||||
|
var toolCtx = context.agentLoop().getToolContext(); |
||||||
|
|
||||||
|
if (trimmed.equals("on") || trimmed.equals("enable")) { |
||||||
|
toolCtx.set("DEBUG_MODE", true); |
||||||
|
sb.append(" Debug mode: ").append(AnsiStyle.green("ENABLED")).append("\n"); |
||||||
|
sb.append(" Tool call tracing is now active.\n"); |
||||||
|
|
||||||
|
// Set InternalLogger to DEBUG level if available
|
||||||
|
Object loggerObj = toolCtx.get("INTERNAL_LOGGER"); |
||||||
|
if (loggerObj instanceof InternalLogger logger) { |
||||||
|
logger.setLevel(InternalLogger.Level.DEBUG); |
||||||
|
sb.append(" Internal log level: DEBUG\n"); |
||||||
|
} |
||||||
|
|
||||||
|
} else if (trimmed.equals("off") || trimmed.equals("disable")) { |
||||||
|
toolCtx.set("DEBUG_MODE", false); |
||||||
|
sb.append(" Debug mode: ").append(AnsiStyle.red("DISABLED")).append("\n"); |
||||||
|
|
||||||
|
Object loggerObj = toolCtx.get("INTERNAL_LOGGER"); |
||||||
|
if (loggerObj instanceof InternalLogger logger) { |
||||||
|
logger.setLevel(InternalLogger.Level.NORMAL); |
||||||
|
sb.append(" Internal log level: NORMAL\n"); |
||||||
|
} |
||||||
|
|
||||||
|
} else if (trimmed.startsWith("log")) { |
||||||
|
// /debug logs [N] — show recent internal logs
|
||||||
|
int count = 20; |
||||||
|
String[] parts = trimmed.split("\\s+"); |
||||||
|
if (parts.length > 1) { |
||||||
|
try { count = Integer.parseInt(parts[1]); } catch (NumberFormatException ignored) {} |
||||||
|
} |
||||||
|
|
||||||
|
Object loggerObj = toolCtx.get("INTERNAL_LOGGER"); |
||||||
|
if (loggerObj instanceof InternalLogger logger) { |
||||||
|
sb.append(AnsiStyle.bold(" Recent Logs (last " + count + ")\n\n")); |
||||||
|
String logs = logger.getRecent(count); |
||||||
|
if (logs.isEmpty()) { |
||||||
|
sb.append(" No logs recorded yet.\n"); |
||||||
|
} else { |
||||||
|
sb.append(AnsiStyle.dim(logs)); |
||||||
|
} |
||||||
|
} else { |
||||||
|
sb.append(" InternalLogger not available.\n"); |
||||||
|
} |
||||||
|
|
||||||
|
} else if (trimmed.equals("tools")) { |
||||||
|
// /debug tools — show tool call stats from ToolContext
|
||||||
|
sb.append(AnsiStyle.bold(" Tool Call Tracing\n\n")); |
||||||
|
Object metrics = toolCtx.get("METRICS_COLLECTOR"); |
||||||
|
if (metrics != null) { |
||||||
|
sb.append(" Use /performance for detailed tool stats.\n"); |
||||||
|
} else { |
||||||
|
sb.append(" No metrics collector available.\n"); |
||||||
|
} |
||||||
|
|
||||||
|
} else { |
||||||
|
// Status
|
||||||
|
boolean debugOn = Boolean.TRUE.equals(toolCtx.get("DEBUG_MODE")); |
||||||
|
sb.append(" Status: ").append(debugOn |
||||||
|
? AnsiStyle.green("ENABLED") : AnsiStyle.dim("disabled")).append("\n\n"); |
||||||
|
|
||||||
|
sb.append(AnsiStyle.bold(" Subcommands\n")); |
||||||
|
sb.append(" /debug on Enable debug mode\n"); |
||||||
|
sb.append(" /debug off Disable debug mode\n"); |
||||||
|
sb.append(" /debug logs Show recent internal logs\n"); |
||||||
|
sb.append(" /debug logs 50 Show last 50 log entries\n"); |
||||||
|
sb.append(" /debug tools Show tool call tracing info\n"); |
||||||
|
} |
||||||
|
|
||||||
|
return sb.toString(); |
||||||
|
} |
||||||
|
} |
||||||
@ -0,0 +1,114 @@ |
|||||||
|
package com.claudecode.command.impl; |
||||||
|
|
||||||
|
import com.claudecode.command.CommandContext; |
||||||
|
import com.claudecode.command.SlashCommand; |
||||||
|
import com.claudecode.console.AnsiStyle; |
||||||
|
|
||||||
|
import java.io.File; |
||||||
|
import java.lang.management.ManagementFactory; |
||||||
|
import java.lang.management.MemoryMXBean; |
||||||
|
import java.lang.management.MemoryPoolMXBean; |
||||||
|
import java.lang.management.MemoryUsage; |
||||||
|
import java.nio.file.Path; |
||||||
|
import java.time.LocalDateTime; |
||||||
|
import java.time.format.DateTimeFormatter; |
||||||
|
import java.util.List; |
||||||
|
|
||||||
|
/** |
||||||
|
* /heapdump 命令 —— JVM 堆转储(Java 独有优势)。 |
||||||
|
* 使用 HotSpotDiagnosticMXBean 进行堆转储。 |
||||||
|
*/ |
||||||
|
public class HeapdumpCommand implements SlashCommand { |
||||||
|
|
||||||
|
@Override |
||||||
|
public String name() { return "heapdump"; } |
||||||
|
|
||||||
|
@Override |
||||||
|
public String description() { return "Generate JVM heap dump (Java advantage)"; } |
||||||
|
|
||||||
|
@Override |
||||||
|
public String execute(String args, CommandContext context) { |
||||||
|
String trimmed = (args == null) ? "" : args.trim(); |
||||||
|
|
||||||
|
StringBuilder sb = new StringBuilder(); |
||||||
|
sb.append("\n").append(AnsiStyle.bold(" 📦 JVM Heap Dump\n")); |
||||||
|
sb.append(" ").append("─".repeat(40)).append("\n\n"); |
||||||
|
|
||||||
|
if (trimmed.equals("info") || trimmed.isEmpty()) { |
||||||
|
// Show memory pool details
|
||||||
|
MemoryMXBean memBean = ManagementFactory.getMemoryMXBean(); |
||||||
|
MemoryUsage heap = memBean.getHeapMemoryUsage(); |
||||||
|
MemoryUsage nonHeap = memBean.getNonHeapMemoryUsage(); |
||||||
|
|
||||||
|
sb.append(AnsiStyle.bold(" Heap Memory\n")); |
||||||
|
sb.append(" Used: ").append(formatBytes(heap.getUsed())).append("\n"); |
||||||
|
sb.append(" Committed: ").append(formatBytes(heap.getCommitted())).append("\n"); |
||||||
|
sb.append(" Max: ").append(formatBytes(heap.getMax())).append("\n\n"); |
||||||
|
|
||||||
|
sb.append(AnsiStyle.bold(" Non-Heap Memory\n")); |
||||||
|
sb.append(" Used: ").append(formatBytes(nonHeap.getUsed())).append("\n"); |
||||||
|
sb.append(" Committed: ").append(formatBytes(nonHeap.getCommitted())).append("\n\n"); |
||||||
|
|
||||||
|
sb.append(AnsiStyle.bold(" Memory Pools\n")); |
||||||
|
for (MemoryPoolMXBean pool : ManagementFactory.getMemoryPoolMXBeans()) { |
||||||
|
MemoryUsage usage = pool.getUsage(); |
||||||
|
if (usage != null && usage.getUsed() > 0) { |
||||||
|
sb.append(" ").append(String.format("%-25s", pool.getName())) |
||||||
|
.append(formatBytes(usage.getUsed())).append("\n"); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
sb.append("\n").append(AnsiStyle.dim(" Run /heapdump dump to generate a heap dump file")); |
||||||
|
|
||||||
|
} else if (trimmed.startsWith("dump")) { |
||||||
|
// Determine output path
|
||||||
|
String timestamp = LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyyMMdd-HHmmss")); |
||||||
|
String filename = trimmed.length() > 5 ? trimmed.substring(5).trim() : ""; |
||||||
|
if (filename.isEmpty()) { |
||||||
|
filename = "heapdump-" + timestamp + ".hprof"; |
||||||
|
} |
||||||
|
Path dumpPath = Path.of(System.getProperty("user.dir"), filename); |
||||||
|
|
||||||
|
try { |
||||||
|
var hotspot = ManagementFactory.getPlatformMXBean( |
||||||
|
com.sun.management.HotSpotDiagnosticMXBean.class); |
||||||
|
hotspot.dumpHeap(dumpPath.toString(), true); |
||||||
|
long fileSize = dumpPath.toFile().length(); |
||||||
|
sb.append(" ✅ Heap dump saved to:\n"); |
||||||
|
sb.append(" ").append(AnsiStyle.cyan(dumpPath.toString())).append("\n"); |
||||||
|
sb.append(" Size: ").append(formatBytes(fileSize)).append("\n\n"); |
||||||
|
sb.append(AnsiStyle.dim(" Analyze with: jhat, MAT, or VisualVM")); |
||||||
|
} catch (Exception e) { |
||||||
|
sb.append(" ❌ Failed to create heap dump: ").append(e.getMessage()).append("\n"); |
||||||
|
sb.append(AnsiStyle.dim(" Requires HotSpot JVM (OpenJDK or Oracle JDK)")); |
||||||
|
} |
||||||
|
|
||||||
|
} else if (trimmed.equals("gc")) { |
||||||
|
// Trigger GC and report
|
||||||
|
long beforeUsed = Runtime.getRuntime().totalMemory() - Runtime.getRuntime().freeMemory(); |
||||||
|
System.gc(); |
||||||
|
long afterUsed = Runtime.getRuntime().totalMemory() - Runtime.getRuntime().freeMemory(); |
||||||
|
long freed = beforeUsed - afterUsed; |
||||||
|
|
||||||
|
sb.append(" 🗑 Garbage collection triggered\n"); |
||||||
|
sb.append(" Before: ").append(formatBytes(beforeUsed)).append("\n"); |
||||||
|
sb.append(" After: ").append(formatBytes(afterUsed)).append("\n"); |
||||||
|
sb.append(" Freed: ").append(AnsiStyle.green(formatBytes(Math.max(0, freed)))).append("\n"); |
||||||
|
|
||||||
|
} else { |
||||||
|
sb.append(AnsiStyle.bold(" Subcommands\n")); |
||||||
|
sb.append(" /heapdump Show memory pool info\n"); |
||||||
|
sb.append(" /heapdump dump Generate .hprof file\n"); |
||||||
|
sb.append(" /heapdump gc Trigger garbage collection\n"); |
||||||
|
} |
||||||
|
|
||||||
|
return sb.toString(); |
||||||
|
} |
||||||
|
|
||||||
|
private String formatBytes(long bytes) { |
||||||
|
if (bytes < 0) return "N/A"; |
||||||
|
if (bytes >= 1_073_741_824) return String.format("%.1fGB", bytes / 1_073_741_824.0); |
||||||
|
if (bytes >= 1_048_576) return String.format("%.1fMB", bytes / 1_048_576.0); |
||||||
|
return String.format("%.0fKB", bytes / 1_024.0); |
||||||
|
} |
||||||
|
} |
||||||
@ -0,0 +1,60 @@ |
|||||||
|
package com.claudecode.command.impl; |
||||||
|
|
||||||
|
import com.claudecode.command.CommandContext; |
||||||
|
import com.claudecode.command.SlashCommand; |
||||||
|
import com.claudecode.console.AnsiStyle; |
||||||
|
import com.claudecode.core.RateLimiter; |
||||||
|
|
||||||
|
import java.util.List; |
||||||
|
|
||||||
|
/** |
||||||
|
* /reset-limits 命令 —— 重置速率限制。 |
||||||
|
*/ |
||||||
|
public class ResetLimitsCommand implements SlashCommand { |
||||||
|
|
||||||
|
@Override |
||||||
|
public String name() { return "reset-limits"; } |
||||||
|
|
||||||
|
@Override |
||||||
|
public String description() { return "Reset rate limits and cooldowns"; } |
||||||
|
|
||||||
|
@Override |
||||||
|
public List<String> aliases() { return List.of("rl"); } |
||||||
|
|
||||||
|
@Override |
||||||
|
public String execute(String args, CommandContext context) { |
||||||
|
StringBuilder sb = new StringBuilder(); |
||||||
|
sb.append("\n").append(AnsiStyle.bold(" 🔄 Rate Limit Reset\n")); |
||||||
|
sb.append(" ").append("─".repeat(40)).append("\n\n"); |
||||||
|
|
||||||
|
if (context.agentLoop() == null) { |
||||||
|
return sb.append(" No active agent loop\n").toString(); |
||||||
|
} |
||||||
|
|
||||||
|
var toolCtx = context.agentLoop().getToolContext(); |
||||||
|
Object limiterObj = toolCtx.get("RATE_LIMITER"); |
||||||
|
|
||||||
|
if (limiterObj instanceof RateLimiter limiter) { |
||||||
|
// Show current state first
|
||||||
|
sb.append(AnsiStyle.bold(" Before Reset\n")); |
||||||
|
sb.append(" Remaining (api): ").append(limiter.getRemaining("api")).append("\n"); |
||||||
|
sb.append(" Remaining (tool): ").append(limiter.getRemaining("tool")).append("\n\n"); |
||||||
|
|
||||||
|
// Reset
|
||||||
|
limiter.resetAll(); |
||||||
|
|
||||||
|
sb.append(AnsiStyle.bold(" After Reset\n")); |
||||||
|
sb.append(" Remaining (api): ").append(AnsiStyle.green( |
||||||
|
String.valueOf(limiter.getRemaining("api")))).append("\n"); |
||||||
|
sb.append(" Remaining (tool): ").append(AnsiStyle.green( |
||||||
|
String.valueOf(limiter.getRemaining("tool")))).append("\n"); |
||||||
|
sb.append(" Concurrent slots: ").append(AnsiStyle.green("all available")).append("\n\n"); |
||||||
|
sb.append(" ✅ Rate limits have been reset.\n"); |
||||||
|
} else { |
||||||
|
sb.append(" No rate limiter configured.\n"); |
||||||
|
sb.append(AnsiStyle.dim(" Rate limiting is not active in the current session.\n")); |
||||||
|
} |
||||||
|
|
||||||
|
return sb.toString(); |
||||||
|
} |
||||||
|
} |
||||||
@ -0,0 +1,83 @@ |
|||||||
|
package com.claudecode.command.impl; |
||||||
|
|
||||||
|
import com.claudecode.command.CommandContext; |
||||||
|
import com.claudecode.command.SlashCommand; |
||||||
|
import com.claudecode.console.AnsiStyle; |
||||||
|
|
||||||
|
import java.time.Instant; |
||||||
|
import java.util.LinkedList; |
||||||
|
import java.util.List; |
||||||
|
import java.util.concurrent.ConcurrentLinkedDeque; |
||||||
|
|
||||||
|
/** |
||||||
|
* /trace 命令 —— 请求/响应追踪。 |
||||||
|
* 显示 API 调用追踪信息(模型调用、tool 调用链等)。 |
||||||
|
*/ |
||||||
|
public class TraceCommand implements SlashCommand { |
||||||
|
|
||||||
|
@Override |
||||||
|
public String name() { return "trace"; } |
||||||
|
|
||||||
|
@Override |
||||||
|
public String description() { return "Show request/response tracing"; } |
||||||
|
|
||||||
|
@Override |
||||||
|
public String execute(String args, CommandContext context) { |
||||||
|
String trimmed = (args == null) ? "" : args.trim(); |
||||||
|
|
||||||
|
StringBuilder sb = new StringBuilder(); |
||||||
|
sb.append("\n").append(AnsiStyle.bold(" 🔍 Request Tracing\n")); |
||||||
|
sb.append(" ").append("─".repeat(40)).append("\n\n"); |
||||||
|
|
||||||
|
if (context.agentLoop() == null) { |
||||||
|
return sb.append(" No active agent loop\n").toString(); |
||||||
|
} |
||||||
|
|
||||||
|
var toolCtx = context.agentLoop().getToolContext(); |
||||||
|
|
||||||
|
if (trimmed.equals("on") || trimmed.equals("enable")) { |
||||||
|
toolCtx.set("TRACE_ENABLED", true); |
||||||
|
sb.append(" Tracing: ").append(AnsiStyle.green("ENABLED")).append("\n"); |
||||||
|
sb.append(" API calls and tool executions will be traced.\n"); |
||||||
|
|
||||||
|
} else if (trimmed.equals("off") || trimmed.equals("disable")) { |
||||||
|
toolCtx.set("TRACE_ENABLED", false); |
||||||
|
sb.append(" Tracing: ").append(AnsiStyle.red("DISABLED")).append("\n"); |
||||||
|
|
||||||
|
} else if (trimmed.equals("clear")) { |
||||||
|
toolCtx.set("TRACE_LOG", null); |
||||||
|
sb.append(" Trace log cleared.\n"); |
||||||
|
|
||||||
|
} else { |
||||||
|
// Show current trace info
|
||||||
|
boolean traceOn = Boolean.TRUE.equals(toolCtx.get("TRACE_ENABLED")); |
||||||
|
sb.append(" Status: ").append(traceOn |
||||||
|
? AnsiStyle.green("ENABLED") : AnsiStyle.dim("disabled")).append("\n\n"); |
||||||
|
|
||||||
|
// Show thread info
|
||||||
|
sb.append(AnsiStyle.bold(" Active Threads\n")); |
||||||
|
Thread.getAllStackTraces().entrySet().stream() |
||||||
|
.filter(e -> e.getKey().getName().startsWith("agent") |
||||||
|
|| e.getKey().getName().contains("tool") |
||||||
|
|| e.getKey().getName().contains("http")) |
||||||
|
.limit(10) |
||||||
|
.forEach(e -> { |
||||||
|
Thread t = e.getKey(); |
||||||
|
sb.append(" ").append(String.format("%-30s", t.getName())) |
||||||
|
.append(AnsiStyle.dim(t.getState().toString())).append("\n"); |
||||||
|
}); |
||||||
|
|
||||||
|
// Show recent conversation turns
|
||||||
|
sb.append("\n").append(AnsiStyle.bold(" Conversation State\n")); |
||||||
|
sb.append(" Session ID: ").append(context.agentLoop().getToolContext() |
||||||
|
.get("SESSION_ID") != null ? toolCtx.get("SESSION_ID") : "default").append("\n"); |
||||||
|
|
||||||
|
sb.append("\n").append(AnsiStyle.bold(" Subcommands\n")); |
||||||
|
sb.append(" /trace on Enable tracing\n"); |
||||||
|
sb.append(" /trace off Disable tracing\n"); |
||||||
|
sb.append(" /trace clear Clear trace log\n"); |
||||||
|
} |
||||||
|
|
||||||
|
return sb.toString(); |
||||||
|
} |
||||||
|
} |
||||||
@ -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("]")); |
||||||
|
} |
||||||
|
} |
||||||
Loading…
Reference in new issue