copilot-session-usage 0.9.1__tar.gz → 0.9.3__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (130) hide show
  1. {copilot_session_usage-0.9.1 → copilot_session_usage-0.9.3}/PKG-INFO +1 -1
  2. {copilot_session_usage-0.9.1 → copilot_session_usage-0.9.3}/docs/source/explanation/how-cost-estimation-works.md +19 -14
  3. {copilot_session_usage-0.9.1 → copilot_session_usage-0.9.3}/docs/source/how-to/analyze-specific-session.md +1 -1
  4. {copilot_session_usage-0.9.1 → copilot_session_usage-0.9.3}/docs/source/reference/pricing.md +13 -7
  5. {copilot_session_usage-0.9.1 → copilot_session_usage-0.9.3}/src/copilot_session_usage/_internal/core.py +35 -26
  6. {copilot_session_usage-0.9.1 → copilot_session_usage-0.9.3}/src/copilot_session_usage/data/models-and-pricing.lock +3 -3
  7. {copilot_session_usage-0.9.1 → copilot_session_usage-0.9.3}/src/copilot_session_usage/data/models-and-pricing.yml +13 -0
  8. {copilot_session_usage-0.9.1 → copilot_session_usage-0.9.3}/tests/test_core.py +42 -0
  9. {copilot_session_usage-0.9.1 → copilot_session_usage-0.9.3}/tests/test_rendering.py +41 -7
  10. {copilot_session_usage-0.9.1 → copilot_session_usage-0.9.3}/.editorconfig +0 -0
  11. {copilot_session_usage-0.9.1 → copilot_session_usage-0.9.3}/.gitattributes +0 -0
  12. {copilot_session_usage-0.9.1 → copilot_session_usage-0.9.3}/.github/ISSUE_TEMPLATE/bug_report.md +0 -0
  13. {copilot_session_usage-0.9.1 → copilot_session_usage-0.9.3}/.github/ISSUE_TEMPLATE/feature_request.md +0 -0
  14. {copilot_session_usage-0.9.1 → copilot_session_usage-0.9.3}/.github/changes/requests/skill-breakdown/01-request.md +0 -0
  15. {copilot_session_usage-0.9.1 → copilot_session_usage-0.9.3}/.github/guidelines/git-commit-message.guideline.md +0 -0
  16. {copilot_session_usage-0.9.1 → copilot_session_usage-0.9.3}/.github/guidelines/knowledge-base.guidelines.md +0 -0
  17. {copilot_session_usage-0.9.1 → copilot_session_usage-0.9.3}/.github/pull_request_template.md +0 -0
  18. {copilot_session_usage-0.9.1 → copilot_session_usage-0.9.3}/.github/skills/consolidate-knowledge-base/SKILL.md +0 -0
  19. {copilot_session_usage-0.9.1 → copilot_session_usage-0.9.3}/.github/skills/gh-commit-changes/SKILL.md +0 -0
  20. {copilot_session_usage-0.9.1 → copilot_session_usage-0.9.3}/.github/skills/gh-create-pull-request/SKILL.md +0 -0
  21. {copilot_session_usage-0.9.1 → copilot_session_usage-0.9.3}/.github/skills/gh-release-notes/SKILL.md +0 -0
  22. {copilot_session_usage-0.9.1 → copilot_session_usage-0.9.3}/.github/skills/gh-release-notes/scripts/generate_release_notes.py +0 -0
  23. {copilot_session_usage-0.9.1 → copilot_session_usage-0.9.3}/.github/skills/record-finding/README.md +0 -0
  24. {copilot_session_usage-0.9.1 → copilot_session_usage-0.9.3}/.github/skills/record-finding/SKILL.md +0 -0
  25. {copilot_session_usage-0.9.1 → copilot_session_usage-0.9.3}/.github/workflows/ci.yml +0 -0
  26. {copilot_session_usage-0.9.1 → copilot_session_usage-0.9.3}/.github/workflows/publish.yml +0 -0
  27. {copilot_session_usage-0.9.1 → copilot_session_usage-0.9.3}/.github/workflows/refresh-pricing.yml +0 -0
  28. {copilot_session_usage-0.9.1 → copilot_session_usage-0.9.3}/.github/workflows/release-notes.yml +0 -0
  29. {copilot_session_usage-0.9.1 → copilot_session_usage-0.9.3}/.github/workflows/release.yml +0 -0
  30. {copilot_session_usage-0.9.1 → copilot_session_usage-0.9.3}/.gitignore +0 -0
  31. {copilot_session_usage-0.9.1 → copilot_session_usage-0.9.3}/.readthedocs.yaml +0 -0
  32. {copilot_session_usage-0.9.1 → copilot_session_usage-0.9.3}/AGENTS.md +0 -0
  33. {copilot_session_usage-0.9.1 → copilot_session_usage-0.9.3}/CHANGELOG.md +0 -0
  34. {copilot_session_usage-0.9.1 → copilot_session_usage-0.9.3}/CONSTITUTION.md +0 -0
  35. {copilot_session_usage-0.9.1 → copilot_session_usage-0.9.3}/CONTRIBUTING.md +0 -0
  36. {copilot_session_usage-0.9.1 → copilot_session_usage-0.9.3}/LICENSE +0 -0
  37. {copilot_session_usage-0.9.1 → copilot_session_usage-0.9.3}/README.md +0 -0
  38. {copilot_session_usage-0.9.1 → copilot_session_usage-0.9.3}/docs/articles/presentation.md +0 -0
  39. {copilot_session_usage-0.9.1 → copilot_session_usage-0.9.3}/docs/internal/automated_release_proces.md +0 -0
  40. {copilot_session_usage-0.9.1 → copilot_session_usage-0.9.3}/docs/source/_static/changelog.js +0 -0
  41. {copilot_session_usage-0.9.1 → copilot_session_usage-0.9.3}/docs/source/_static/custom.css +0 -0
  42. {copilot_session_usage-0.9.1 → copilot_session_usage-0.9.3}/docs/source/changelog.md +0 -0
  43. {copilot_session_usage-0.9.1 → copilot_session_usage-0.9.3}/docs/source/conf.py +0 -0
  44. {copilot_session_usage-0.9.1 → copilot_session_usage-0.9.3}/docs/source/explanation/index.md +0 -0
  45. {copilot_session_usage-0.9.1 → copilot_session_usage-0.9.3}/docs/source/how-to/add-commit-trailer.md +0 -0
  46. {copilot_session_usage-0.9.1 → copilot_session_usage-0.9.3}/docs/source/how-to/batch-and-spending.md +0 -0
  47. {copilot_session_usage-0.9.1 → copilot_session_usage-0.9.3}/docs/source/how-to/copilot-cli-provider.md +0 -0
  48. {copilot_session_usage-0.9.1 → copilot_session_usage-0.9.3}/docs/source/how-to/export-json.md +0 -0
  49. {copilot_session_usage-0.9.1 → copilot_session_usage-0.9.3}/docs/source/how-to/index.md +0 -0
  50. {copilot_session_usage-0.9.1 → copilot_session_usage-0.9.3}/docs/source/how-to/wsl2.md +0 -0
  51. {copilot_session_usage-0.9.1 → copilot_session_usage-0.9.3}/docs/source/index.md +0 -0
  52. {copilot_session_usage-0.9.1 → copilot_session_usage-0.9.3}/docs/source/installation.md +0 -0
  53. {copilot_session_usage-0.9.1 → copilot_session_usage-0.9.3}/docs/source/reference/api.md +0 -0
  54. {copilot_session_usage-0.9.1 → copilot_session_usage-0.9.3}/docs/source/reference/cli.md +0 -0
  55. {copilot_session_usage-0.9.1 → copilot_session_usage-0.9.3}/docs/source/reference/index.md +0 -0
  56. {copilot_session_usage-0.9.1 → copilot_session_usage-0.9.3}/docs/source/tutorials/getting-started.md +0 -0
  57. {copilot_session_usage-0.9.1 → copilot_session_usage-0.9.3}/docs/source/tutorials/index.md +0 -0
  58. {copilot_session_usage-0.9.1 → copilot_session_usage-0.9.3}/guidelines.yml +0 -0
  59. {copilot_session_usage-0.9.1 → copilot_session_usage-0.9.3}/justfile +0 -0
  60. {copilot_session_usage-0.9.1 → copilot_session_usage-0.9.3}/knowledge/_schema/Base.schema.yaml +0 -0
  61. {copilot_session_usage-0.9.1 → copilot_session_usage-0.9.3}/knowledge/_schema/Concept.schema.yaml +0 -0
  62. {copilot_session_usage-0.9.1 → copilot_session_usage-0.9.3}/knowledge/_schema/Experiment.schema.yaml +0 -0
  63. {copilot_session_usage-0.9.1 → copilot_session_usage-0.9.3}/knowledge/_schema/Finding.schema.yaml +0 -0
  64. {copilot_session_usage-0.9.1 → copilot_session_usage-0.9.3}/knowledge/_schema/Hypothesis.schema.yaml +0 -0
  65. {copilot_session_usage-0.9.1 → copilot_session_usage-0.9.3}/knowledge/_schema/Outcome.schema.yaml +0 -0
  66. {copilot_session_usage-0.9.1 → copilot_session_usage-0.9.3}/knowledge/_schema/Playbook.schema.yaml +0 -0
  67. {copilot_session_usage-0.9.1 → copilot_session_usage-0.9.3}/knowledge/_schema/Principle.schema.yaml +0 -0
  68. {copilot_session_usage-0.9.1 → copilot_session_usage-0.9.3}/knowledge/_schema/Reference.schema.yaml +0 -0
  69. {copilot_session_usage-0.9.1 → copilot_session_usage-0.9.3}/knowledge/_schema/Structure.schema.yaml +0 -0
  70. {copilot_session_usage-0.9.1 → copilot_session_usage-0.9.3}/knowledge/concepts/copilot-cli.md +0 -0
  71. {copilot_session_usage-0.9.1 → copilot_session_usage-0.9.3}/knowledge/concepts/index.md +0 -0
  72. {copilot_session_usage-0.9.1 → copilot_session_usage-0.9.3}/knowledge/concepts/overview.md +0 -0
  73. {copilot_session_usage-0.9.1 → copilot_session_usage-0.9.3}/knowledge/concepts/session-cost-analysis.md +0 -0
  74. {copilot_session_usage-0.9.1 → copilot_session_usage-0.9.3}/knowledge/concepts/threshold-based-pricing.md +0 -0
  75. {copilot_session_usage-0.9.1 → copilot_session_usage-0.9.3}/knowledge/experiments/index.md +0 -0
  76. {copilot_session_usage-0.9.1 → copilot_session_usage-0.9.3}/knowledge/experiments/verify-subagent-cost-attribution.md +0 -0
  77. {copilot_session_usage-0.9.1 → copilot_session_usage-0.9.3}/knowledge/findings/2026.07.02-00.00-subagent-logs-runsubagent-prefix.md +0 -0
  78. {copilot_session_usage-0.9.1 → copilot_session_usage-0.9.3}/knowledge/findings/2026.07.02-22.00-title-generation-not-counted-as-model-turn.md +0 -0
  79. {copilot_session_usage-0.9.1 → copilot_session_usage-0.9.3}/knowledge/findings/2026.07.02-23.00-cache-write-approximation.md +0 -0
  80. {copilot_session_usage-0.9.1 → copilot_session_usage-0.9.3}/knowledge/findings/2026.07.13-14.27-release-notes-overreported-maintainer-changes.md +0 -0
  81. {copilot_session_usage-0.9.1 → copilot_session_usage-0.9.3}/knowledge/findings/2026.07.24-15.01-actions-token-cannot-create-pr.md +0 -0
  82. {copilot_session_usage-0.9.1 → copilot_session_usage-0.9.3}/knowledge/findings/2026.08.05-16.24-utility-models-appear-in-logs-but-are-not-billed.md +0 -0
  83. {copilot_session_usage-0.9.1 → copilot_session_usage-0.9.3}/knowledge/findings/2026.10.07-17.30-cli-billed-nanoaiu-and-cache-write.md +0 -0
  84. {copilot_session_usage-0.9.1 → copilot_session_usage-0.9.3}/knowledge/findings/2026.10.07-request-billing-and-cache-write.md +0 -0
  85. {copilot_session_usage-0.9.1 → copilot_session_usage-0.9.3}/knowledge/findings/index.md +0 -0
  86. {copilot_session_usage-0.9.1 → copilot_session_usage-0.9.3}/knowledge/guides/automation-scripts.md +0 -0
  87. {copilot_session_usage-0.9.1 → copilot_session_usage-0.9.3}/knowledge/guides/cost-optimization.md +0 -0
  88. {copilot_session_usage-0.9.1 → copilot_session_usage-0.9.3}/knowledge/guides/index.md +0 -0
  89. {copilot_session_usage-0.9.1 → copilot_session_usage-0.9.3}/knowledge/guides/wsl2-setup.md +0 -0
  90. {copilot_session_usage-0.9.1 → copilot_session_usage-0.9.3}/knowledge/ideas/index.md +0 -0
  91. {copilot_session_usage-0.9.1 → copilot_session_usage-0.9.3}/knowledge/ideas/multi-session-efficiency-analytics.md +0 -0
  92. {copilot_session_usage-0.9.1 → copilot_session_usage-0.9.3}/knowledge/index.md +0 -0
  93. {copilot_session_usage-0.9.1 → copilot_session_usage-0.9.3}/knowledge/log.md +0 -0
  94. {copilot_session_usage-0.9.1 → copilot_session_usage-0.9.3}/knowledge/principles/findings-are-immutable.md +0 -0
  95. {copilot_session_usage-0.9.1 → copilot_session_usage-0.9.3}/knowledge/principles/index.md +0 -0
  96. {copilot_session_usage-0.9.1 → copilot_session_usage-0.9.3}/knowledge/reference/debug-log-format.md +0 -0
  97. {copilot_session_usage-0.9.1 → copilot_session_usage-0.9.3}/knowledge/reference/index.md +0 -0
  98. {copilot_session_usage-0.9.1 → copilot_session_usage-0.9.3}/knowledge/reference/pricing-formats.md +0 -0
  99. {copilot_session_usage-0.9.1 → copilot_session_usage-0.9.3}/knowledge/structures/cache-cost-approximation.md +0 -0
  100. {copilot_session_usage-0.9.1 → copilot_session_usage-0.9.3}/knowledge/structures/index.md +0 -0
  101. {copilot_session_usage-0.9.1 → copilot_session_usage-0.9.3}/knowledge/structures/knowledge-base-information-types.md +0 -0
  102. {copilot_session_usage-0.9.1 → copilot_session_usage-0.9.3}/knowledge/structures/session-discovery-algorithm.md +0 -0
  103. {copilot_session_usage-0.9.1 → copilot_session_usage-0.9.3}/knowledge/structures/subagent-cost-tracking.md +0 -0
  104. {copilot_session_usage-0.9.1 → copilot_session_usage-0.9.3}/knowledge/structures/vscode-copilot-extension.md +0 -0
  105. {copilot_session_usage-0.9.1 → copilot_session_usage-0.9.3}/pyproject.toml +0 -0
  106. {copilot_session_usage-0.9.1 → copilot_session_usage-0.9.3}/scripts/refresh_pricing.py +0 -0
  107. {copilot_session_usage-0.9.1 → copilot_session_usage-0.9.3}/skills/copilot-session-usage/SKILL.md +0 -0
  108. {copilot_session_usage-0.9.1 → copilot_session_usage-0.9.3}/skills/copilot-session-usage/references/span-analysis-template.md +0 -0
  109. {copilot_session_usage-0.9.1 → copilot_session_usage-0.9.3}/src/copilot_session_usage/__init__.py +0 -0
  110. {copilot_session_usage-0.9.1 → copilot_session_usage-0.9.3}/src/copilot_session_usage/_internal/__init__.py +0 -0
  111. {copilot_session_usage-0.9.1 → copilot_session_usage-0.9.3}/src/copilot_session_usage/_internal/copilot_cli.py +0 -0
  112. {copilot_session_usage-0.9.1 → copilot_session_usage-0.9.3}/src/copilot_session_usage/_internal/git.py +0 -0
  113. {copilot_session_usage-0.9.1 → copilot_session_usage-0.9.3}/src/copilot_session_usage/_internal/vscode.py +0 -0
  114. {copilot_session_usage-0.9.1 → copilot_session_usage-0.9.3}/src/copilot_session_usage/api.py +0 -0
  115. {copilot_session_usage-0.9.1 → copilot_session_usage-0.9.3}/src/copilot_session_usage/cli.py +0 -0
  116. {copilot_session_usage-0.9.1 → copilot_session_usage-0.9.3}/src/copilot_session_usage/data/__init__.py +0 -0
  117. {copilot_session_usage-0.9.1 → copilot_session_usage-0.9.3}/src/copilot_session_usage/data/custom-models-pricing.yml +0 -0
  118. {copilot_session_usage-0.9.1 → copilot_session_usage-0.9.3}/tests/conftest.py +0 -0
  119. {copilot_session_usage-0.9.1 → copilot_session_usage-0.9.3}/tests/test_api.py +0 -0
  120. {copilot_session_usage-0.9.1 → copilot_session_usage-0.9.3}/tests/test_cli.py +0 -0
  121. {copilot_session_usage-0.9.1 → copilot_session_usage-0.9.3}/tests/test_copilot_cli.py +0 -0
  122. {copilot_session_usage-0.9.1 → copilot_session_usage-0.9.3}/tests/test_coverage_gaps.py +0 -0
  123. {copilot_session_usage-0.9.1 → copilot_session_usage-0.9.3}/tests/test_generate_release_notes.py +0 -0
  124. {copilot_session_usage-0.9.1 → copilot_session_usage-0.9.3}/tests/test_git.py +0 -0
  125. {copilot_session_usage-0.9.1 → copilot_session_usage-0.9.3}/tests/test_pricing_runtime.py +0 -0
  126. {copilot_session_usage-0.9.1 → copilot_session_usage-0.9.3}/tests/test_release_notes.py +0 -0
  127. {copilot_session_usage-0.9.1 → copilot_session_usage-0.9.3}/tests/test_vscode.py +0 -0
  128. {copilot_session_usage-0.9.1 → copilot_session_usage-0.9.3}/tests/test_vscode_platform.py +0 -0
  129. {copilot_session_usage-0.9.1 → copilot_session_usage-0.9.3}/uv.lock +0 -0
  130. {copilot_session_usage-0.9.1 → copilot_session_usage-0.9.3}/uv.toml +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: copilot-session-usage
3
- Version: 0.9.1
3
+ Version: 0.9.3
4
4
  Summary: Usage and cost analytics for GitHub Copilot and Copilot-CLI session logs
5
5
  Project-URL: Homepage, https://github.com/gsemet/copilot-session-usage
6
6
  Project-URL: Documentation, https://copilot-session-usage.readthedocs.io/en/stable/
@@ -126,6 +126,7 @@ Subagent names are extracted from the JSONL filename
126
126
  (`runSubagent-<Name>-functions.runSubagent:<id>.jsonl`) and from
127
127
  `child_session_ref` events in `main.jsonl`.
128
128
 
129
+ (skill-attribution)=
129
130
  ## Skill attribution
130
131
 
131
132
  Skills are detected from four sources in the debug logs:
@@ -237,22 +238,25 @@ provider's use of `copilotUsageNanoAiu` (1 nanoAIU = 1e-11 USD):
237
238
  cumulative across resumes (the largest value from the last
238
239
  `session.shutdown` or `session.usage_checkpoint`).
239
240
  - Each `model_breakdown` row uses that model's `totalNanoAiu`. Without it, the
240
- model's tokens are priced exactly: `inputTokens` includes cache reads and
241
- cache writes, so fresh input is `inputTokens - cacheReadTokens -
242
- cacheWriteTokens`, and only `cacheWriteTokens` are charged at the
243
- cache-write rate. The pricing tier is chosen from the average request size,
244
- not the session sum.
241
+ model's tokens are priced using the reported cache-write count:
242
+ `inputTokens` includes cache reads and cache writes, so fresh input is
243
+ `inputTokens - cacheReadTokens - cacheWriteTokens`, and only
244
+ `cacheWriteTokens` are charged at the cache-write rate. The pricing tier is
245
+ chosen from the average request size, not the session sum. This fallback
246
+ remains an estimate because individual request sizes and discounts are absent.
245
247
  - `subagents` lists the main agent and each subagent from
246
248
  `session.shutdown.agentMetrics`, with their own billed `totalNanoAiu`.
247
- - Per-model and per-agent metrics omit segments that ended without a
248
- `session.shutdown` (a crash or kill before a resume). That remainder is
249
- reported as `total.unattributed_usd`, with a diagnostic, so
250
- breakdowns plus `unattributed_usd` always equal the total.
251
-
252
- Across 101 local CLI/App sessions, totals matched the billed `totalNanoAiu`
253
- exactly and breakdowns reconciled with it. Pricing tokens with the exact
254
- cache-write count reproduced per-model billed amounts for 89 of 96 models;
255
- the rest included long-context requests or discounts invisible in aggregates.
249
+ - Per-model and per-agent metrics can omit segments that ended without a
250
+ `session.shutdown` (a crash or kill before a resume). A positive remainder
251
+ between session billing and attributed model costs is reported as
252
+ `total.unattributed_usd`, with a diagnostic. Displayed totals and breakdowns
253
+ are rounded to different precisions.
254
+
255
+ A local investigation on 2026-10-07 found that billed totals and breakdowns
256
+ reconciled across 101 CLI/App sessions. Pricing tokens with the reported
257
+ cache-write count at the base tier reproduced per-model billed amounts for
258
+ 89 of 96 model entries. These are dated observations, not guarantees for future
259
+ sessions; see the [recorded finding](https://github.com/gsemet/copilot-session-usage/blob/5142026b2ec4c46432c4ff037ebea37030f3b56f/knowledge/findings/2026.10.07-17.30-cli-billed-nanoaiu-and-cache-write.md).
256
260
 
257
261
  ### When usage is unavailable
258
262
 
@@ -264,6 +268,7 @@ has no `session.shutdown` event. In that case:
264
268
  fabricated zero.
265
269
  - If a `session.usage_checkpoint` exists, its cumulative `totalNanoAiu`
266
270
  provides `total.estimated_usd`, entirely reported as `unattributed_usd`.
271
+ Token totals and cache ratio remain unavailable.
267
272
  - `diagnostics` explains exactly what is unavailable and why.
268
273
  - Batch/aggregate operations exclude sessions with unavailable evidence from
269
274
  their sums (rather than counting them as zero) and report how many were
@@ -109,7 +109,7 @@ invocation. It assigns the whole request context, not just the skill text or usa
109
109
  caused by that skill. Automatically loaded skills, overlapping skills, and later
110
110
  unrelated work cannot be reliably separated; requests before any invocation are
111
111
  grouped under `unknown`. CLI/App per-skill tokens and costs remain unavailable.
112
- See [How cost estimation works](../explanation/how-cost-estimation-works.md).
112
+ See [Skill attribution](../explanation/how-cost-estimation-works.md#skill-attribution).
113
113
  :::
114
114
 
115
115
  ```bash
@@ -189,21 +189,27 @@ matching pricing prefix. Aggregate-only CLI summaries cannot recover individual
189
189
  request context sizes; without a billed figure, their tier is chosen from the
190
190
  average request size.
191
191
 
192
+ Pricing row order does not affect tier selection, including at the boundary.
193
+ Supported thresholds are the current `≤`/`<=` and `>` comparisons, plus inclusive
194
+ `≥`/`>=`, with integer token counts and optional `K` or `M` suffixes.
195
+ Missing or `Not applicable` thresholds remain unbounded. Other syntax is rejected;
196
+ range expressions and future multi-tier formats are not inferred.
197
+
192
198
  ## Copilot CLI/App limitations
193
199
 
194
- CLI/App costs use Copilot's billed `totalNanoAiu` (session, per model, per
195
- agent), so totals are dollar-accurate. The event log has only cumulative
196
- usage, so:
200
+ When reported, Copilot CLI/App costs use `totalNanoAiu` for the session, each
201
+ model, and each agent rather than token-based estimates. The event log has
202
+ cumulative usage rather than per-request token counts:
197
203
 
198
204
  - **No per-skill cost.** There is no per-request usage to split by skill;
199
205
  `skills.breakdown` is empty. Detected skills and tool counts remain.
200
- - **Unattributed remainder.** Per-model and per-agent metrics omit session
206
+ - **Unattributed remainder.** Per-model and per-agent metrics can omit session
201
207
  segments that ended without `session.shutdown` (crash or kill before a
202
208
  resume). That cost is in `total.unattributed_usd`, not in any breakdown.
203
- - **Active sessions** report only the latest checkpoint total, entirely
204
- unattributed.
209
+ - **Active sessions** report the latest checkpoint total when available,
210
+ entirely unattributed, with token totals remaining unavailable.
205
211
  - **Token fallback** (no billed figure) cannot see per-request long-context
206
- tiers or discounts and is an estimate.
212
+ tiers or discounts and is an estimate. Reported costs are rounded.
207
213
 
208
214
  ## Custom pricing
209
215
 
@@ -151,23 +151,22 @@ def _clean_model_display_name(name: str) -> str:
151
151
  return cleaned.strip()
152
152
 
153
153
 
154
- def _parse_threshold(threshold: str) -> int | None:
155
- """Parse a threshold string from the YAML into a token count.
156
-
157
- Examples:
158
- "≤ 272K" → 272_000
159
- "> 272K" → None (unbounded / long-context tier)
160
- "Not applicable" → None
161
- """
162
- if not threshold or threshold.lower() in ("not applicable", "n/a", ""):
163
- return None
164
- match = re.search(r"([0-9]+(?:\.[0-9]+)?)\s*([KM]?)", threshold)
165
- if not match:
166
- return None
167
- value = float(match.group(1))
168
- suffix = match.group(2).upper()
169
- multiplier = {"K": 1_000, "M": 1_000_000}.get(suffix, 1)
170
- return int(value * multiplier)
154
+ def _parse_threshold(threshold: str | None) -> tuple[int, int | None]:
155
+ """Parse current pricing comparisons into inclusive integer-token bounds."""
156
+ if threshold is None:
157
+ return 0, None
158
+ if not isinstance(threshold, str):
159
+ raise ValueError(f"unsupported pricing threshold: {threshold!r}")
160
+ if threshold.strip().lower() in ("not applicable", "n/a", ""):
161
+ return 0, None
162
+ match = re.fullmatch(r"\s*(<=|≤|>=|≥|>)\s*([0-9]+)\s*([KM]?)\s*", threshold, re.IGNORECASE)
163
+ if match is None:
164
+ raise ValueError(f"unsupported pricing threshold: {threshold!r}")
165
+ operator, amount, suffix = match.groups()
166
+ tokens = int(amount) * {"K": 1_000, "M": 1_000_000}.get(suffix.upper(), 1)
167
+ if operator in ("<=", "≤"):
168
+ return 0, tokens
169
+ return tokens + (operator == ">"), None
171
170
 
172
171
 
173
172
  def _parse_price(price: str) -> float:
@@ -382,13 +381,15 @@ def _build_pricing_from_yaml(entries: list[dict], source: str) -> dict:
382
381
  continue
383
382
  name = _normalize_model_name(raw_name)
384
383
  display_name = _clean_model_display_name(raw_name)
384
+ lower, upper = _parse_threshold(entry.get("threshold", ""))
385
385
  tier = {
386
386
  "input_per_m": _parse_price(entry.get("input", "0")),
387
387
  "output_per_m": _parse_price(entry.get("output", "0")),
388
388
  "cache_per_m": _parse_price(entry.get("cached_input", "0")),
389
389
  "cache_write_per_m": _parse_price(entry.get("cache_write", "0")),
390
390
  "tier": entry.get("tier", "Default"),
391
- "threshold_tokens": _parse_threshold(entry.get("threshold", "")),
391
+ "threshold_tokens": upper,
392
+ "min_threshold_tokens": lower,
392
393
  "provider": entry.get("provider", "unknown"),
393
394
  "display_name": display_name,
394
395
  }
@@ -759,14 +760,22 @@ def _get_model_rates(model: str, input_tok: int, pricing: dict[str, Any]) -> dic
759
760
  if tiers is None:
760
761
  tiers = models.get("default", [{}])
761
762
 
762
- if len(tiers) == 1:
763
- return tiers[0] # type: ignore[no-any-return]
764
-
765
- for tier in tiers:
766
- threshold = tier.get("threshold_tokens")
767
- if threshold is not None and input_tok <= threshold:
768
- return tier # type: ignore[no-any-return]
769
- return tiers[-1] # type: ignore[no-any-return]
763
+ matches = [
764
+ tier
765
+ for tier in tiers
766
+ if input_tok >= tier.get("min_threshold_tokens", 0)
767
+ and (tier.get("threshold_tokens") is None or input_tok <= tier["threshold_tokens"])
768
+ ]
769
+ if not matches:
770
+ raise ValueError(f"no pricing tier for {model} at {input_tok} input tokens")
771
+ return min( # type: ignore[no-any-return]
772
+ matches,
773
+ key=lambda tier: (
774
+ tier.get("threshold_tokens") is None,
775
+ tier.get("threshold_tokens") or 0,
776
+ -tier.get("min_threshold_tokens", 0),
777
+ ),
778
+ )
770
779
 
771
780
 
772
781
  def model_uses_fallback_pricing(model: str, pricing: dict[str, Any]) -> bool:
@@ -1,7 +1,7 @@
1
1
  {
2
- "_captured": "2026-10-08T09:01:09.111216+00:00",
2
+ "_captured": "2026-10-19T07:49:42.570898+00:00",
3
3
  "_source": "https://raw.githubusercontent.com/github/docs/main/data/tables/copilot/models-and-pricing.yml",
4
4
  "_yaml_path": "/home/runner/work/copilot-session-usage/copilot-session-usage/src/copilot_session_usage/data/models-and-pricing.yml",
5
- "model_count": 45,
6
- "checksum": "d1c7540b412c58b3"
5
+ "model_count": 46,
6
+ "checksum": "c03a304b103a3dd1"
7
7
  }
@@ -406,6 +406,19 @@
406
406
  cached_input: $0.075
407
407
  output: $3.75
408
408
 
409
+ # Promotional pricing expires 2026-12-01. Google hasn't shared post-promo pricing yet.
410
+ # When it does, update input, cached_input, and output below, then remove the
411
+ # [^gemini-4-argon-promo] footnote here and in models-and-pricing.md.
412
+ - model: 'Gemini 4 Argon[^gemini-4-argon-promo]'
413
+ provider: google
414
+ release_status: GA
415
+ category: Powerful
416
+ threshold: 'Not applicable'
417
+ tier: 'Default'
418
+ input: $2.00
419
+ cached_input: $0.10
420
+ output: $10.00
421
+
409
422
  # xAI
410
423
  - model: 'Grok 4.5'
411
424
  provider: xai
@@ -304,6 +304,48 @@ def test_threshold_aware_pricing_exact_boundary():
304
304
  assert rates["input_per_m"] == 2.50
305
305
 
306
306
 
307
+ @pytest.mark.parametrize("reverse", [False, True])
308
+ @pytest.mark.parametrize(
309
+ ("upper", "lower", "boundary"),
310
+ [
311
+ ("≤ 272K", "> 272K", 272_000),
312
+ ("<= 272K", "> 272K", 272_000),
313
+ ("<= 271999", ">= 272K", 271_999),
314
+ ("≤ 271999", "≥ 272K", 271_999),
315
+ ],
316
+ )
317
+ def test_threshold_aware_pricing_is_order_independent(reverse, upper, lower, boundary):
318
+ text = core._read_data_file("models-and-pricing.yml")
319
+ assert text is not None
320
+ rows = [
321
+ row
322
+ for row in core._parse_pricing_entries(text, "test")
323
+ if core._normalize_model_name(row.get("model", "")) == "gpt-5.4"
324
+ ]
325
+ assert len(rows) == 2
326
+ rows = [{**rows[0], "threshold": upper}, {**rows[1], "threshold": lower}]
327
+ pricing = core._build_pricing_from_yaml(list(reversed(rows)) if reverse else rows, "test")
328
+ pricing["models"]["gpt-5.4"].reverse()
329
+ for tokens in [0, boundary - 1, boundary, boundary + 1, 500_000]:
330
+ expected = rows[0] if tokens <= boundary else rows[1]
331
+ assert core._get_model_rates("gpt-5.4", tokens, pricing)["tier"] == expected["tier"]
332
+ cached = tokens // 2
333
+ expected_cost = (
334
+ (tokens - cached) * core._parse_price(expected["input"])
335
+ + cached * core._parse_price(expected["cached_input"])
336
+ + 1000 * core._parse_price(expected["output"])
337
+ ) / 1e6
338
+ assert core.estimate_cost(tokens, 1000, cached, "gpt-5.4", pricing) == pytest.approx(
339
+ expected_cost
340
+ )
341
+
342
+
343
+ def test_threshold_aware_pricing_does_not_ignore_lower_bound():
344
+ pricing = core._build_pricing_from_yaml([{"model": "Test", "threshold": ">= 100K"}], "test")
345
+ with pytest.raises(ValueError, match="no pricing tier"):
346
+ core._get_model_rates("test", 99_999, pricing)
347
+
348
+
307
349
  def test_estimate_cost_with_threshold():
308
350
  pricing = {
309
351
  "models": {
@@ -13,19 +13,53 @@ from copilot_session_usage._internal import core
13
13
 
14
14
 
15
15
  def test_parse_threshold_not_applicable():
16
- assert core._parse_threshold("Not applicable") is None
17
- assert core._parse_threshold("n/a") is None
18
- assert core._parse_threshold("") is None
19
- assert core._parse_threshold(None) is None
16
+ assert core._parse_threshold("Not applicable") == (0, None)
17
+ assert core._parse_threshold("n/a") == (0, None)
18
+ assert core._parse_threshold("") == (0, None)
19
+ assert core._parse_threshold(None) == (0, None)
20
20
 
21
21
 
22
22
  def test_parse_threshold_unbounded():
23
- # "> 272K" is parsed as 272000 by the regex; the docstring is aspirational
24
- assert core._parse_threshold("> 272K") == 272_000
23
+ assert core._parse_threshold("> 272K") == (272_001, None)
25
24
 
26
25
 
27
26
  def test_parse_threshold_no_match():
28
- assert core._parse_threshold("abc") is None
27
+ with pytest.raises(ValueError, match="threshold"):
28
+ core._parse_threshold("abc")
29
+
30
+
31
+ @pytest.mark.parametrize(
32
+ ("threshold", "expected"),
33
+ [
34
+ ("<= 100K", (0, 100_000)),
35
+ (">= 100K", (100_000, None)),
36
+ (" > 100k ", (100_001, None)),
37
+ ("≤ 100K", (0, 100_000)),
38
+ ("≥ 100K", (100_000, None)),
39
+ ("<= 1M", (0, 1_000_000)),
40
+ ],
41
+ )
42
+ def test_parse_threshold_conditions(threshold, expected):
43
+ assert core._parse_threshold(threshold) == expected
44
+
45
+
46
+ @pytest.mark.parametrize(
47
+ "threshold",
48
+ [
49
+ "garbage 100K",
50
+ ">=",
51
+ "100K trailing",
52
+ "200K-100K",
53
+ "< 0",
54
+ "> 2 and < 3",
55
+ ">= 0.5M",
56
+ 100_000,
57
+ True,
58
+ ],
59
+ )
60
+ def test_parse_threshold_invalid_conditions(threshold):
61
+ with pytest.raises(ValueError, match="threshold"):
62
+ core._parse_threshold(threshold)
29
63
 
30
64
 
31
65
  # ─── _parse_price edge cases ──────────────────────────────────────────────────