Skip to content

refactor: convert ModelMetrics, RequestMetrics, TokenUsage to frozen dataclasses (#1066)#1068

Open
microsasa wants to merge 3 commits intomainfrom
issue-1066-frozen-dataclass-metrics-22f066193500edf7
Open

refactor: convert ModelMetrics, RequestMetrics, TokenUsage to frozen dataclasses (#1066)#1068
microsasa wants to merge 3 commits intomainfrom
issue-1066-frozen-dataclass-metrics-22f066193500edf7

Conversation

@microsasa
Copy link
Copy Markdown
Owner

Closes #1066

Summary

Replace mutable Pydantic BaseModel subclasses with @dataclasses.dataclass(frozen=True, slots=True) for TokenUsage, RequestMetrics, and ModelMetrics. These are internal accumulator structs with no Pydantic validators or special features, making them a violation of the coding guideline: "Internal intermediate state uses frozen dataclasses with slots=True."

Changes

src/copilot_usage/models.py

  • TokenUsage, RequestMetrics, ModelMetrics: BaseModel@dataclasses.dataclass(frozen=True, slots=True)
  • add_to_model_metrics: Changed from in-place mutation (→ None) to returning a new ModelMetrics instance (→ ModelMetrics), preventing silent cache corruption
  • copy_model_metrics: Simplified to use dataclasses.replace() — with frozen dataclasses, shallow copy is sufficient since nested types are also frozen
  • merge_model_metrics: Updated to capture return value from add_to_model_metrics

Call site updates

  • src/copilot_usage/parser.py (line 923): Capture return value
  • src/copilot_usage/report.py (line 272): Capture return value + updated docstring

Test updates (tests/copilot_usage/test_models.py)

  • Replaced model_fieldsdataclasses.fields(), model_dump()dataclasses.asdict()
  • test_neither_operand_mutated: Verifies both target and source are unchanged (regression test)
  • test_frozen_prevents_mutation: Verifies FrozenInstanceError on attribute assignment
  • test_accumulates_incrementally: Updated to capture return values
  • Removed mutation-based independence tests (now enforced by frozen=True)
  • Simplified test_no_deep_copy_regression (removed model_copy patch)

Documentation

  • Updated implementation.md to reflect immutable accumulation semantics

Warning

⚠️ Firewall blocked 4 domains

The following domains were blocked by the firewall during workflow execution:

  • astral.sh
  • index.crates.io
  • pypi.org
  • releaseassets.githubusercontent.com

To allow these domains, add them to the network.allowed list in your workflow frontmatter:

network:
  allowed:
    - defaults
    - "astral.sh"
    - "index.crates.io"
    - "pypi.org"
    - "releaseassets.githubusercontent.com"

See Network Configuration for more information.

Generated by Issue Implementer · ● 24.7M ·

…dataclasses (#1066)

Replace mutable Pydantic BaseModel subclasses with frozen dataclasses
(frozen=True, slots=True) for TokenUsage, RequestMetrics, and
ModelMetrics. These are internal accumulator structs with no Pydantic
validators, making them candidates for plain frozen dataclasses per the
coding guidelines.

Changes:
- TokenUsage, RequestMetrics, ModelMetrics: BaseModel → frozen dataclass
- add_to_model_metrics: returns new ModelMetrics instead of mutating
- copy_model_metrics: uses dataclasses.replace (shallow copy sufficient)
- All 5 call sites updated to capture return value
- Tests updated: model_fields → dataclasses.fields, model_dump → asdict
- Added regression test verifying frozen instances reject mutation
- Updated implementation.md docs to reflect immutable semantics

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@microsasa microsasa added the aw Created by agentic workflow label Apr 24, 2026
Copilot AI review requested due to automatic review settings April 24, 2026 05:33
@microsasa microsasa enabled auto-merge April 24, 2026 05:34
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Refactors internal model-metric accumulator types in copilot_usage.models from mutable Pydantic models to immutable frozen/slot dataclasses, updating accumulation helpers and downstream call sites to avoid in-place mutation (and potential cache corruption).

Changes:

  • Converted TokenUsage, RequestMetrics, and ModelMetrics to @dataclasses.dataclass(frozen=True, slots=True).
  • Updated add_to_model_metrics to return a new ModelMetrics and adjusted call sites in parser/report to capture the return value.
  • Updated unit tests and implementation docs to reflect the new immutable semantics.

Reviewed changes

Copilot reviewed 5 out of 5 changed files in this pull request and generated 3 comments.

Show a summary per file
File Description
src/copilot_usage/models.py Converts metric structs to frozen dataclasses and makes accumulation return new instances.
src/copilot_usage/parser.py Updates metric aggregation to capture add_to_model_metrics return value.
src/copilot_usage/report.py Updates aggregation call site and adjusts docstring wording for immutable accumulation.
tests/copilot_usage/test_models.py Migrates tests from Pydantic APIs to dataclasses utilities; adds immutability regression checks.
src/copilot_usage/docs/implementation.md Updates docs to reflect non-mutating accumulation semantics.

Comment on lines 107 to +130
@@ -112,46 +114,47 @@ 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)
Copy link

Copilot AI Apr 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

TokenUsage/RequestMetrics/ModelMetrics are no longer Pydantic models, so they no longer have .model_dump()/.model_fields/.model_copy(). There is still at least one usage of ModelMetrics.model_dump() in the test suite (tests/copilot_usage/test_report.py:5723), which will now fail. Please update those remaining call sites to use dataclasses.asdict() (or another equivalent serialization helper) instead of Pydantic APIs.

Copilot uses AI. Check for mistakes.
Comment on lines 149 to 156
def copy_model_metrics(mm: ModelMetrics) -> ModelMetrics:
"""Create an independent copy of *mm* via explicit construction.
"""Create an independent 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`` are themselves frozen
and can safely be shared.
"""
Copy link

Copilot AI Apr 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

copy_model_metrics() now returns dataclasses.replace(mm), which creates a new ModelMetrics instance but intentionally shares the nested RequestMetrics/TokenUsage references. Since everything is frozen this is safe, but the docstring still says “independent copy”, which reads like a deep copy. Consider rewording to “shallow copy” (or explicitly mention nested objects are shared) to avoid misleading future callers.

Copilot uses AI. Check for mistakes.
Comment thread src/copilot_usage/report.py Outdated
Comment on lines +265 to +266
Each unique model name produces a single accumulated ``ModelMetrics``
instance via immutable addition.
Copy link

Copilot AI Apr 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This docstring says each model name produces “a single accumulated ModelMetrics instance”. With immutable accumulation, every additional merge creates a new ModelMetrics object and reassigns the dict entry. Consider clarifying that the result dict contains one final accumulated instance per model, even though intermediate instances are created during aggregation.

Suggested change
Each unique model name produces a single accumulated ``ModelMetrics``
instance via immutable addition.
The returned dict contains one final accumulated ``ModelMetrics``
value per unique model name. Accumulation uses immutable addition,
so intermediate merged instances may be created while aggregating.

Copilot uses AI. Check for mistakes.
@microsasa microsasa added the aw-ci-fix-attempted CI fixer has attempted to fix CI label Apr 24, 2026
- Fix ruff format violations: break long lines in models.py and parser.py
- Fix test_report.py: replace model_dump() with dataclasses.asdict() for
  ModelMetrics which is now a frozen dataclass

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@github-actions
Copy link
Copy Markdown
Contributor

Commit pushed: 51f3e1b

Generated by CI Fixer

@microsasa
Copy link
Copy Markdown
Owner Author

CI Fix Applied

Fixed the following CI failures:

  1. ruff format violations — Two lines exceeded the 88-char limit:

    • src/copilot_usage/models.py:144cacheWriteTokens addition line in add_to_model_metrics()
    • src/copilot_usage/parser.py:923add_to_model_metrics() call in _build_completed_summary()
  2. Stale Pydantic API in testtests/copilot_usage/test_report.py:5723 still called .model_dump() on ModelMetrics, which is now a frozen dataclass. Replaced with dataclasses.asdict().

Warning

⚠️ Firewall blocked 4 domains

The following domains were blocked by the firewall during workflow execution:

  • astral.sh
  • index.crates.io
  • pypi.org
  • releaseassets.githubusercontent.com

To allow these domains, add them to the network.allowed list in your workflow frontmatter:

network:
  allowed:
    - defaults
    - "astral.sh"
    - "index.crates.io"
    - "pypi.org"
    - "releaseassets.githubusercontent.com"

See Network Configuration for more information.

Generated by CI Fixer · ● 20.5M ·

@microsasa microsasa added the aw-review-response-1 Review response round 1 label Apr 24, 2026
@microsasa
Copy link
Copy Markdown
Owner Author

CI fix already attempted once — stopping to prevent loops. Manual intervention needed.

Generated by CI Fixer · ● 440.7K ·

- models.py: Reword copy_model_metrics docstring from 'independent copy'
  to 'shallow copy' and clarify nested references are shared
- report.py: Clarify _aggregate_model_metrics docstring to note
  intermediate instances during immutable accumulation

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@github-actions
Copy link
Copy Markdown
Contributor

Commit pushed: 02cb749

Generated by Review Responder

Copilot AI review requested due to automatic review settings April 24, 2026 05:57
@microsasa microsasa added the aw-review-response-2 Review response round 2 label Apr 24, 2026
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 6 out of 6 changed files in this pull request and generated no new comments.

@microsasa microsasa added aw-review-response-3 Review response round 3 aw-review-response-4 Responder dispatch round 4 (orchestrator-managed) aw-review-response-5 Responder dispatch round 5 (orchestrator-managed) aw-pr-stuck:review labels Apr 24, 2026
@microsasa
Copy link
Copy Markdown
Owner Author

❌ Pipeline orchestrator: review-response loop reached 5 rounds. Marking as stuck for human review.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

aw Created by agentic workflow aw-ci-fix-attempted CI fixer has attempted to fix CI aw-pr-stuck:review aw-review-response-1 Review response round 1 aw-review-response-2 Review response round 2 aw-review-response-3 Review response round 3 aw-review-response-4 Responder dispatch round 4 (orchestrator-managed) aw-review-response-5 Responder dispatch round 5 (orchestrator-managed)

Projects

None yet

2 participants