feat(billing): add billing source classification for sessions and update cost handling

This commit is contained in:
null 2026-05-24 16:37:52 -05:00
parent 28e103452b
commit a8e560a586
3 changed files with 41 additions and 7 deletions

View File

@ -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,

View File

@ -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

View File

@ -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,