From a8e560a5862772d19848117873f1d6fc8c2ec8c9 Mon Sep 17 00:00:00 2001 From: null Date: Sun, 24 May 2026 16:37:52 -0500 Subject: [PATCH] feat(billing): add billing source classification for sessions and update cost handling --- backend/app/api/claude_code.py | 1 + backend/app/schemas/claude_code.py | 3 +- backend/app/services/claude_code_reader.py | 44 +++++++++++++++++++--- 3 files changed, 41 insertions(+), 7 deletions(-) diff --git a/backend/app/api/claude_code.py b/backend/app/api/claude_code.py index 5a4277c..c8e6cca 100644 --- a/backend/app/api/claude_code.py +++ b/backend/app/api/claude_code.py @@ -66,6 +66,7 @@ def _session_to_read(s: reader.ClaudeSession) -> ClaudeSessionRead: total=s.tokens.total, ), cost_usd=s.cost_usd, + billing_source=s.billing_source, message_count=s.message_count, first_message_at=s.first_message_at, last_message_at=s.last_message_at, diff --git a/backend/app/schemas/claude_code.py b/backend/app/schemas/claude_code.py index e69ece8..e6c631c 100644 --- a/backend/app/schemas/claude_code.py +++ b/backend/app/schemas/claude_code.py @@ -93,7 +93,8 @@ class ClaudeSessionRead(SQLModel): title: str | None = None models: list[str] tokens: SessionTokensRead - cost_usd: float + cost_usd: float # always $0 for subscription sessions + billing_source: str # "subscription" | "api" message_count: int first_message_at: datetime | None = None last_message_at: datetime | None = None diff --git a/backend/app/services/claude_code_reader.py b/backend/app/services/claude_code_reader.py index c80c941..771c6b1 100644 --- a/backend/app/services/claude_code_reader.py +++ b/backend/app/services/claude_code_reader.py @@ -23,6 +23,32 @@ logger = get_logger(__name__) ACTIVE_WINDOW_MINUTES = 30 +# --------------------------------------------------------------------------- +# Billing classification +# --------------------------------------------------------------------------- + +# Entrypoints that run against the user's Claude subscription (flat-rate, not +# per-token API billing). Sessions from these clients cost $0 in real money +# even though they consume tokens. +_SUBSCRIPTION_ENTRYPOINTS: frozenset[str] = frozenset({ + "claude-vscode", + "claude-jetbrains", + "claude-web", +}) + + +def _billing_source(entrypoints: set[str]) -> str: + """Return 'subscription' or 'api' based on the session's entrypoints. + + Any session that touched a subscription client is treated as subscription + (cost = $0). Sessions with no entrypoint metadata default to subscription + because JSONL files are written by local Claude Code clients, not the API. + """ + if not entrypoints or entrypoints & _SUBSCRIPTION_ENTRYPOINTS: + return "subscription" + return "api" + + # --------------------------------------------------------------------------- # Pricing (USD per million tokens) — mirrors runtime_usage.DEFAULT_MODEL_PRICING # --------------------------------------------------------------------------- @@ -86,7 +112,8 @@ class ClaudeSession: title: str | None models: list[str] tokens: SessionTokens - cost_usd: float + cost_usd: float # $0 for subscription sessions, real cost for api sessions + billing_source: str # "subscription" | "api" message_count: int # assistant turns first_message_at: datetime | None last_message_at: datetime | None @@ -221,11 +248,15 @@ def _parse_session_file(path: Path) -> ClaudeSession | None: return None model_list = sorted(models) - primary_model = model_list[0] if model_list else "" - cost = _price(primary_model, tokens.input, tokens.output, tokens.cache_read, tokens.cache_write) - for m in model_list[1:]: - # Additional models — approximate with same token split (rare) - cost += _price(m, 0, 0, 0, 0) + billing = _billing_source(entrypoints) + + if billing == "subscription": + cost = 0.0 + else: + primary_model = model_list[0] if model_list else "" + cost = _price(primary_model, tokens.input, tokens.output, tokens.cache_read, tokens.cache_write) + for m in model_list[1:]: + cost += _price(m, 0, 0, 0, 0) now = datetime.utcnow() is_active = bool(last_ts and (now - last_ts) < timedelta(minutes=ACTIVE_WINDOW_MINUTES)) @@ -238,6 +269,7 @@ def _parse_session_file(path: Path) -> ClaudeSession | None: models=model_list, tokens=tokens, cost_usd=round(cost, 6), + billing_source=billing, message_count=message_count, first_message_at=first_ts, last_message_at=last_ts,