-
Notifications
You must be signed in to change notification settings - Fork 1
refactor: convert ModelMetrics, RequestMetrics, TokenUsage to frozen dataclasses (#1066) #1068
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -6,6 +6,7 @@ | |
| """ | ||
|
|
||
| import builtins | ||
| import dataclasses | ||
| import math | ||
| from datetime import UTC, datetime | ||
| from enum import StrEnum | ||
|
|
@@ -103,7 +104,8 @@ class SessionContext(BaseModel): | |
| cwd: str | None = None | ||
|
|
||
|
|
||
| class TokenUsage(BaseModel): | ||
| @dataclasses.dataclass(frozen=True, slots=True) | ||
| class TokenUsage: | ||
| """Token usage breakdown for a single model.""" | ||
|
|
||
| inputTokens: int = 0 | ||
|
|
@@ -112,46 +114,48 @@ class TokenUsage(BaseModel): | |
| cacheWriteTokens: int = 0 | ||
|
|
||
|
|
||
| class RequestMetrics(BaseModel): | ||
| @dataclasses.dataclass(frozen=True, slots=True) | ||
| class RequestMetrics: | ||
| """Request count and cost for a single model.""" | ||
|
|
||
| count: int = 0 | ||
| cost: int = 0 | ||
|
|
||
|
|
||
| class ModelMetrics(BaseModel): | ||
| @dataclasses.dataclass(frozen=True, slots=True) | ||
| class ModelMetrics: | ||
| """Combined request + usage metrics for one model.""" | ||
|
|
||
| requests: RequestMetrics = Field(default_factory=RequestMetrics) | ||
| usage: TokenUsage = Field(default_factory=TokenUsage) | ||
| requests: RequestMetrics = dataclasses.field(default_factory=RequestMetrics) | ||
| usage: TokenUsage = dataclasses.field(default_factory=TokenUsage) | ||
|
|
||
|
|
||
| def add_to_model_metrics(target: ModelMetrics, source: ModelMetrics) -> None: | ||
| """Add *source* fields into *target* in-place.""" | ||
| target.requests.count += source.requests.count | ||
| target.requests.cost += source.requests.cost | ||
| target.usage.inputTokens += source.usage.inputTokens | ||
| target.usage.outputTokens += source.usage.outputTokens | ||
| target.usage.cacheReadTokens += source.usage.cacheReadTokens | ||
| target.usage.cacheWriteTokens += source.usage.cacheWriteTokens | ||
| def add_to_model_metrics(target: ModelMetrics, source: ModelMetrics) -> ModelMetrics: | ||
| """Return a new ``ModelMetrics`` with *source* fields added to *target*.""" | ||
| return ModelMetrics( | ||
| requests=RequestMetrics( | ||
| count=target.requests.count + source.requests.count, | ||
| cost=target.requests.cost + source.requests.cost, | ||
| ), | ||
| usage=TokenUsage( | ||
| inputTokens=target.usage.inputTokens + source.usage.inputTokens, | ||
| outputTokens=target.usage.outputTokens + source.usage.outputTokens, | ||
| cacheReadTokens=target.usage.cacheReadTokens + source.usage.cacheReadTokens, | ||
| cacheWriteTokens=target.usage.cacheWriteTokens | ||
| + source.usage.cacheWriteTokens, | ||
| ), | ||
| ) | ||
|
|
||
|
|
||
| def copy_model_metrics(mm: ModelMetrics) -> ModelMetrics: | ||
| """Create an independent copy of *mm* via explicit construction. | ||
| """Create a shallow copy of *mm*. | ||
|
|
||
| Builds new ``ModelMetrics``/``RequestMetrics``/``TokenUsage`` instances | ||
| instead of using Pydantic's ``model_copy(deep=True)`` which delegates to | ||
| ``copy.deepcopy`` and is significantly slower for simple int fields. | ||
| With frozen dataclasses every instance is already immutable, so a | ||
| shallow ``dataclasses.replace`` (no field overrides) is sufficient — | ||
| the nested ``RequestMetrics`` and ``TokenUsage`` references are shared, | ||
| which is safe because they are themselves frozen. | ||
| """ | ||
|
Comment on lines
150
to
157
|
||
| return ModelMetrics( | ||
| requests=RequestMetrics(count=mm.requests.count, cost=mm.requests.cost), | ||
| usage=TokenUsage( | ||
| inputTokens=mm.usage.inputTokens, | ||
| outputTokens=mm.usage.outputTokens, | ||
| cacheReadTokens=mm.usage.cacheReadTokens, | ||
| cacheWriteTokens=mm.usage.cacheWriteTokens, | ||
| ), | ||
| ) | ||
| return dataclasses.replace(mm) | ||
|
|
||
|
|
||
| def merge_model_metrics( | ||
|
|
@@ -162,7 +166,7 @@ def merge_model_metrics( | |
| result = {name: copy_model_metrics(mm) for name, mm in base.items()} | ||
| for name, mm in additional.items(): | ||
| if name in result: | ||
| add_to_model_metrics(result[name], mm) | ||
| result[name] = add_to_model_metrics(result[name], mm) | ||
| else: | ||
| result[name] = copy_model_metrics(mm) | ||
| return result | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
TokenUsage/RequestMetrics/ModelMetricsare no longer Pydantic models, so they no longer have.model_dump()/.model_fields/.model_copy(). There is still at least one usage ofModelMetrics.model_dump()in the test suite (tests/copilot_usage/test_report.py:5723), which will now fail. Please update those remaining call sites to usedataclasses.asdict()(or another equivalent serialization helper) instead of Pydantic APIs.