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
501 lines
16 KiB
Python
Executable File
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()
|