Files
1jehuang--jcode/scripts/compare_token_usage.py
T
wehub-resource-sync a789495a98
FreeBSD Smoke / FreeBSD Smoke (x86_64) (push) Has been cancelled
CI / Quality Guardrails (push) Has been cancelled
CI / Build & Test (macos-latest) (push) Has been cancelled
CI / Build & Test (ubuntu-latest) (push) Has been cancelled
CI / Build & Test (windows-latest) (push) Has been cancelled
CI / Format (push) Has been cancelled
CI / PowerShell Syntax (push) Has been cancelled
CI / Windows Cross-Target Check (Linux) (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:10:34 +08:00

501 lines
16 KiB
Python
Executable File

#!/usr/bin/env python3
"""
Compare token usage between jcode and Claude Code CLI.
This script runs the same prompts through both tools and compares their token usage.
The goal is to verify that jcode's token consumption is within expected bounds
compared to the official Claude Code CLI.
NOTE: jcode typically uses FEWER tokens than Claude CLI because:
1. jcode has a smaller/simpler system prompt
2. jcode registers fewer tools (Claude CLI has many built-in tools)
3. Different prompt caching behavior
The test PASSES if jcode uses fewer tokens OR at most 50% more tokens.
Using more tokens would indicate a problem with the system prompt or tool registration.
Usage:
python scripts/compare_token_usage.py [--verbose] [--runs N]
Requirements:
- jcode built and in PATH or at target/release/jcode
- claude CLI installed and authenticated
- Both should use the same model (claude-opus-4-5-20251101 by default)
"""
import argparse
import json
import os
import subprocess
import sys
import tempfile
import time
from dataclasses import dataclass
from pathlib import Path
from typing import Optional
@dataclass
class TokenUsage:
"""Token usage from a single run."""
input_tokens: int
output_tokens: int
cache_read_tokens: int
cache_creation_tokens: int
total_cost_usd: Optional[float] = None
duration_ms: Optional[int] = None
@property
def total_input(self) -> int:
"""Total input tokens including cache."""
return self.input_tokens + self.cache_read_tokens + self.cache_creation_tokens
@property
def total(self) -> int:
"""Total tokens (input + output)."""
return self.total_input + self.output_tokens
@dataclass
class RunResult:
"""Result of a single tool run."""
tool: str
prompt: str
usage: TokenUsage
success: bool
output: str
error: Optional[str] = None
def find_jcode_binary() -> str:
"""Find the jcode binary."""
# Check target/release first
repo_root = Path(__file__).parent.parent
release_binary = repo_root / "target" / "release" / "jcode"
if release_binary.exists():
return str(release_binary)
# Check PATH
result = subprocess.run(["which", "jcode"], capture_output=True, text=True)
if result.returncode == 0:
return result.stdout.strip()
raise FileNotFoundError("jcode binary not found. Run 'cargo build --release' first.")
def run_claude_cli(prompt: str, workdir: str, model: str = "opus") -> RunResult:
"""Run the Claude Code CLI and capture token usage."""
try:
result = subprocess.run(
[
"claude",
"-p",
"--output-format", "json",
"--dangerously-skip-permissions",
"--model", model,
prompt,
],
capture_output=True,
text=True,
cwd=workdir,
timeout=120,
)
if result.returncode != 0 and not result.stdout:
return RunResult(
tool="claude",
prompt=prompt,
usage=TokenUsage(0, 0, 0, 0),
success=False,
output="",
error=result.stderr or f"Exit code {result.returncode}",
)
# Parse JSON output
data = json.loads(result.stdout)
usage = data.get("usage", {})
token_usage = TokenUsage(
input_tokens=usage.get("input_tokens", 0),
output_tokens=usage.get("output_tokens", 0),
cache_read_tokens=usage.get("cache_read_input_tokens", 0),
cache_creation_tokens=usage.get("cache_creation_input_tokens", 0),
total_cost_usd=data.get("total_cost_usd"),
duration_ms=data.get("duration_ms"),
)
return RunResult(
tool="claude",
prompt=prompt,
usage=token_usage,
success=not data.get("is_error", False),
output=data.get("result", ""),
)
except subprocess.TimeoutExpired:
return RunResult(
tool="claude",
prompt=prompt,
usage=TokenUsage(0, 0, 0, 0),
success=False,
output="",
error="Timeout after 120s",
)
except json.JSONDecodeError as e:
return RunResult(
tool="claude",
prompt=prompt,
usage=TokenUsage(0, 0, 0, 0),
success=False,
output=result.stdout if 'result' in dir() else "",
error=f"JSON parse error: {e}",
)
except Exception as e:
return RunResult(
tool="claude",
prompt=prompt,
usage=TokenUsage(0, 0, 0, 0),
success=False,
output="",
error=str(e),
)
def run_jcode(prompt: str, workdir: str, jcode_binary: str, model: str = "claude-opus-4-5-20251101") -> RunResult:
"""Run jcode and capture token usage from trace output."""
try:
# Create a temporary JCODE_HOME to avoid polluting user's sessions
with tempfile.TemporaryDirectory() as tmpdir:
env = os.environ.copy()
env["JCODE_HOME"] = tmpdir
env["JCODE_TRACE"] = "1"
result = subprocess.run(
[
jcode_binary,
"run",
"--no-update",
"--model", model,
prompt,
],
capture_output=True,
text=True,
cwd=workdir,
timeout=120,
env=env,
)
# Parse token usage from trace output in stderr
# Format: [trace] token_usage input=X output=Y cache_read=Z cache_write=W
input_tokens = 0
output_tokens = 0
cache_read = 0
cache_write = 0
for line in result.stderr.split("\n"):
if "[trace] token_usage" in line:
parts = line.split()
for part in parts:
if part.startswith("input="):
input_tokens = int(part.split("=")[1])
elif part.startswith("output="):
output_tokens = int(part.split("=")[1])
elif part.startswith("cache_read="):
cache_read = int(part.split("=")[1])
elif part.startswith("cache_write="):
cache_write = int(part.split("=")[1])
token_usage = TokenUsage(
input_tokens=input_tokens,
output_tokens=output_tokens,
cache_read_tokens=cache_read,
cache_creation_tokens=cache_write,
)
return RunResult(
tool="jcode",
prompt=prompt,
usage=token_usage,
success=result.returncode == 0,
output=result.stdout,
error=None if result.returncode == 0 else result.stderr,
)
except subprocess.TimeoutExpired:
return RunResult(
tool="jcode",
prompt=prompt,
usage=TokenUsage(0, 0, 0, 0),
success=False,
output="",
error="Timeout after 120s",
)
except Exception as e:
return RunResult(
tool="jcode",
prompt=prompt,
usage=TokenUsage(0, 0, 0, 0),
success=False,
output="",
error=str(e),
)
def compare_usage(claude_result: RunResult, jcode_result: RunResult, verbose: bool = False) -> dict:
"""Compare token usage between Claude CLI and jcode."""
c = claude_result.usage
j = jcode_result.usage
# Calculate differences
input_diff = j.input_tokens - c.input_tokens
output_diff = j.output_tokens - c.output_tokens
cache_read_diff = j.cache_read_tokens - c.cache_read_tokens
cache_write_diff = j.cache_creation_tokens - c.cache_creation_tokens
total_diff = j.total - c.total
# Calculate percentages (avoid division by zero)
def pct_diff(a: int, b: int) -> float:
if b == 0:
return 0.0 if a == 0 else float('inf')
return ((a - b) / b) * 100
input_pct = pct_diff(j.input_tokens, c.input_tokens)
output_pct = pct_diff(j.output_tokens, c.output_tokens)
total_pct = pct_diff(j.total, c.total)
return {
"claude": {
"input": c.input_tokens,
"output": c.output_tokens,
"cache_read": c.cache_read_tokens,
"cache_write": c.cache_creation_tokens,
"total": c.total,
"cost_usd": c.total_cost_usd,
"duration_ms": c.duration_ms,
},
"jcode": {
"input": j.input_tokens,
"output": j.output_tokens,
"cache_read": j.cache_read_tokens,
"cache_write": j.cache_creation_tokens,
"total": j.total,
},
"diff": {
"input": input_diff,
"output": output_diff,
"cache_read": cache_read_diff,
"cache_write": cache_write_diff,
"total": total_diff,
},
"pct_diff": {
"input": input_pct,
"output": output_pct,
"total": total_pct,
},
}
def print_comparison(comparison: dict, prompt: str, verbose: bool = False):
"""Print a formatted comparison."""
print(f"\n{'='*60}")
print(f"Prompt: {prompt[:50]}..." if len(prompt) > 50 else f"Prompt: {prompt}")
print(f"{'='*60}")
c = comparison["claude"]
j = comparison["jcode"]
d = comparison["diff"]
p = comparison["pct_diff"]
print(f"\n{'Metric':<20} {'Claude':<15} {'jcode':<15} {'Diff':<15} {'% Diff':<10}")
print("-" * 75)
print(f"{'Input tokens':<20} {c['input']:<15} {j['input']:<15} {d['input']:+<15} {p['input']:+.1f}%")
print(f"{'Output tokens':<20} {c['output']:<15} {j['output']:<15} {d['output']:+<15} {p['output']:+.1f}%")
print(f"{'Cache read':<20} {c['cache_read']:<15} {j['cache_read']:<15} {d['cache_read']:+<15}")
print(f"{'Cache write':<20} {c['cache_write']:<15} {j['cache_write']:<15} {d['cache_write']:+<15}")
print("-" * 75)
print(f"{'TOTAL':<20} {c['total']:<15} {j['total']:<15} {d['total']:+<15} {p['total']:+.1f}%")
if c.get("cost_usd"):
print(f"\nClaude CLI cost: ${c['cost_usd']:.6f}")
if c.get("duration_ms"):
print(f"Claude CLI duration: {c['duration_ms']}ms")
def run_test_suite(verbose: bool = False, runs: int = 1) -> list:
"""Run the full test suite."""
# Test prompts - simple ones that don't require tools
prompts = [
"Reply with a single word: test",
"What is 2 + 2? Reply with just the number.",
"List three primary colors, one per line.",
]
jcode_binary = find_jcode_binary()
print(f"Using jcode binary: {jcode_binary}")
print(f"Running {len(prompts)} prompts, {runs} run(s) each\n")
results = []
with tempfile.TemporaryDirectory() as workdir:
for prompt in prompts:
for run_num in range(runs):
print(f"\n[Run {run_num + 1}/{runs}] Testing: {prompt[:40]}...")
# Run both tools
print(" Running Claude CLI...", end=" ", flush=True)
claude_result = run_claude_cli(prompt, workdir)
if claude_result.success:
print(f"OK ({claude_result.usage.total} tokens)")
else:
print(f"FAILED: {claude_result.error}")
if verbose:
print(f" Output: {claude_result.output[:200]}")
# Small delay to avoid rate limiting
time.sleep(1)
print(" Running jcode...", end=" ", flush=True)
jcode_result = run_jcode(prompt, workdir, jcode_binary)
if jcode_result.success:
print(f"OK ({jcode_result.usage.total} tokens)")
else:
print(f"FAILED: {jcode_result.error}")
if verbose:
print(f" Output: {jcode_result.output[:200]}")
if claude_result.success and jcode_result.success:
comparison = compare_usage(claude_result, jcode_result, verbose)
results.append({
"prompt": prompt,
"run": run_num + 1,
"comparison": comparison,
})
if verbose:
print_comparison(comparison, prompt, verbose)
# Delay between prompts
time.sleep(2)
return results
def summarize_results(results: list) -> bool:
"""Print summary of all results. Returns True if test passed."""
if not results:
print("\nNo successful results to summarize.")
return False
print(f"\n{'='*60}")
print("SUMMARY")
print(f"{'='*60}")
total_claude = sum(r["comparison"]["claude"]["total"] for r in results)
total_jcode = sum(r["comparison"]["jcode"]["total"] for r in results)
total_diff = total_jcode - total_claude
# Also compare just input+output (excluding cache)
total_claude_io = sum(
r["comparison"]["claude"]["input"] + r["comparison"]["claude"]["output"]
for r in results
)
total_jcode_io = sum(
r["comparison"]["jcode"]["input"] + r["comparison"]["jcode"]["output"]
for r in results
)
if total_claude > 0:
pct_diff = ((total_jcode - total_claude) / total_claude) * 100
else:
pct_diff = 0
print(f"\nTotal runs: {len(results)}")
print(f"\n--- Total Tokens (including cache) ---")
print(f"Claude CLI: {total_claude}")
print(f"jcode: {total_jcode}")
print(f"Difference: {total_diff:+} ({pct_diff:+.1f}%)")
print(f"\n--- Input + Output only (excluding cache) ---")
print(f"Claude CLI: {total_claude_io}")
print(f"jcode: {total_jcode_io}")
if total_claude_io > 0:
io_pct_diff = ((total_jcode_io - total_claude_io) / total_claude_io) * 100
print(f"Difference: {total_jcode_io - total_claude_io:+} ({io_pct_diff:+.1f}%)")
# Check if within acceptable bounds
# jcode using fewer tokens is always good (negative diff)
# jcode using more tokens is acceptable up to MAX_OVERHEAD_PCT
MAX_OVERHEAD_PCT = 50 # Allow up to 50% more tokens (for different system prompts)
passed = True
if pct_diff <= 0:
print(f"\n✅ PASS: jcode uses {abs(pct_diff):.1f}% fewer tokens than Claude CLI")
elif pct_diff <= MAX_OVERHEAD_PCT:
print(f"\n✅ PASS: jcode uses {pct_diff:.1f}% more tokens (within {MAX_OVERHEAD_PCT}% threshold)")
else:
print(f"\n❌ FAIL: jcode uses {pct_diff:.1f}% more tokens (exceeds {MAX_OVERHEAD_PCT}% threshold)")
passed = False
# Per-prompt breakdown
print("\nPer-prompt breakdown:")
print(f"{'Prompt':<40} {'Claude':<10} {'jcode':<10} {'Diff':<10}")
print("-" * 70)
for r in results:
prompt = r["prompt"][:37] + "..." if len(r["prompt"]) > 40 else r["prompt"]
c_total = r["comparison"]["claude"]["total"]
j_total = r["comparison"]["jcode"]["total"]
diff = j_total - c_total
print(f"{prompt:<40} {c_total:<10} {j_total:<10} {diff:+<10}")
return passed
def main():
parser = argparse.ArgumentParser(
description="Compare token usage between jcode and Claude Code CLI"
)
parser.add_argument(
"--verbose", "-v",
action="store_true",
help="Show detailed output for each run",
)
parser.add_argument(
"--runs", "-n",
type=int,
default=1,
help="Number of runs per prompt (default: 1)",
)
parser.add_argument(
"--json",
action="store_true",
help="Output results as JSON",
)
args = parser.parse_args()
print("Token Usage Comparison: jcode vs Claude Code CLI")
print("=" * 50)
try:
results = run_test_suite(verbose=args.verbose, runs=args.runs)
if args.json:
print(json.dumps(results, indent=2))
# For JSON mode, check if all runs succeeded
sys.exit(0 if results else 1)
else:
passed = summarize_results(results)
sys.exit(0 if passed else 1)
except FileNotFoundError as e:
print(f"Error: {e}", file=sys.stderr)
sys.exit(1)
except KeyboardInterrupt:
print("\nInterrupted by user")
sys.exit(130)
if __name__ == "__main__":
main()