feat(billing): add billing source classification for sessions and update cost handling
This commit is contained in:
parent
28e103452b
commit
a8e560a586
|
|
@ -66,6 +66,7 @@ def _session_to_read(s: reader.ClaudeSession) -> ClaudeSessionRead:
|
||||||
total=s.tokens.total,
|
total=s.tokens.total,
|
||||||
),
|
),
|
||||||
cost_usd=s.cost_usd,
|
cost_usd=s.cost_usd,
|
||||||
|
billing_source=s.billing_source,
|
||||||
message_count=s.message_count,
|
message_count=s.message_count,
|
||||||
first_message_at=s.first_message_at,
|
first_message_at=s.first_message_at,
|
||||||
last_message_at=s.last_message_at,
|
last_message_at=s.last_message_at,
|
||||||
|
|
|
||||||
|
|
@ -93,7 +93,8 @@ class ClaudeSessionRead(SQLModel):
|
||||||
title: str | None = None
|
title: str | None = None
|
||||||
models: list[str]
|
models: list[str]
|
||||||
tokens: SessionTokensRead
|
tokens: SessionTokensRead
|
||||||
cost_usd: float
|
cost_usd: float # always $0 for subscription sessions
|
||||||
|
billing_source: str # "subscription" | "api"
|
||||||
message_count: int
|
message_count: int
|
||||||
first_message_at: datetime | None = None
|
first_message_at: datetime | None = None
|
||||||
last_message_at: datetime | None = None
|
last_message_at: datetime | None = None
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,32 @@ logger = get_logger(__name__)
|
||||||
|
|
||||||
ACTIVE_WINDOW_MINUTES = 30
|
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
|
# Pricing (USD per million tokens) — mirrors runtime_usage.DEFAULT_MODEL_PRICING
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
@ -86,7 +112,8 @@ class ClaudeSession:
|
||||||
title: str | None
|
title: str | None
|
||||||
models: list[str]
|
models: list[str]
|
||||||
tokens: SessionTokens
|
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
|
message_count: int # assistant turns
|
||||||
first_message_at: datetime | None
|
first_message_at: datetime | None
|
||||||
last_message_at: datetime | None
|
last_message_at: datetime | None
|
||||||
|
|
@ -221,11 +248,15 @@ def _parse_session_file(path: Path) -> ClaudeSession | None:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
model_list = sorted(models)
|
model_list = sorted(models)
|
||||||
primary_model = model_list[0] if model_list else ""
|
billing = _billing_source(entrypoints)
|
||||||
cost = _price(primary_model, tokens.input, tokens.output, tokens.cache_read, tokens.cache_write)
|
|
||||||
for m in model_list[1:]:
|
if billing == "subscription":
|
||||||
# Additional models — approximate with same token split (rare)
|
cost = 0.0
|
||||||
cost += _price(m, 0, 0, 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()
|
now = datetime.utcnow()
|
||||||
is_active = bool(last_ts and (now - last_ts) < timedelta(minutes=ACTIVE_WINDOW_MINUTES))
|
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,
|
models=model_list,
|
||||||
tokens=tokens,
|
tokens=tokens,
|
||||||
cost_usd=round(cost, 6),
|
cost_usd=round(cost, 6),
|
||||||
|
billing_source=billing,
|
||||||
message_count=message_count,
|
message_count=message_count,
|
||||||
first_message_at=first_ts,
|
first_message_at=first_ts,
|
||||||
last_message_at=last_ts,
|
last_message_at=last_ts,
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue