agentshim 0.21.0__tar.gz → 0.22.0__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 (136) hide show
  1. {agentshim-0.21.0 → agentshim-0.22.0}/CHANGELOG.md +29 -0
  2. {agentshim-0.21.0 → agentshim-0.22.0}/PKG-INFO +1 -1
  3. {agentshim-0.21.0 → agentshim-0.22.0}/agentshim/__init__.py +3 -1
  4. {agentshim-0.21.0 → agentshim-0.22.0}/agentshim/core/__init__.py +2 -1
  5. {agentshim-0.21.0 → agentshim-0.22.0}/agentshim/core/usage.py +16 -0
  6. {agentshim-0.21.0 → agentshim-0.22.0}/agentshim/pricing.py +1 -1
  7. agentshim-0.22.0/agentshim/providers/claude/cost.py +140 -0
  8. {agentshim-0.21.0 → agentshim-0.22.0}/agentshim/providers/claude/events.py +4 -0
  9. {agentshim-0.21.0 → agentshim-0.22.0}/agentshim/providers/claude/model_data.py +22 -2
  10. {agentshim-0.21.0 → agentshim-0.22.0}/agentshim/providers/claude/parser.py +41 -5
  11. {agentshim-0.21.0 → agentshim-0.22.0}/agentshim/providers/claude/stream_transport.py +9 -2
  12. {agentshim-0.21.0 → agentshim-0.22.0}/agentshim/providers/codex/app_server/transport.py +6 -1
  13. {agentshim-0.21.0 → agentshim-0.22.0}/pyproject.toml +1 -1
  14. {agentshim-0.21.0 → agentshim-0.22.0}/.gitignore +0 -0
  15. {agentshim-0.21.0 → agentshim-0.22.0}/LICENSE +0 -0
  16. {agentshim-0.21.0 → agentshim-0.22.0}/README.md +0 -0
  17. {agentshim-0.21.0 → agentshim-0.22.0}/agentshim/agent.py +0 -0
  18. {agentshim-0.21.0 → agentshim-0.22.0}/agentshim/core/_files.py +0 -0
  19. {agentshim-0.21.0 → agentshim-0.22.0}/agentshim/core/auth.py +0 -0
  20. {agentshim-0.21.0 → agentshim-0.22.0}/agentshim/core/checkpoints.py +0 -0
  21. {agentshim-0.21.0 → agentshim-0.22.0}/agentshim/core/clock.py +0 -0
  22. {agentshim-0.21.0 → agentshim-0.22.0}/agentshim/core/conversation.py +0 -0
  23. {agentshim-0.21.0 → agentshim-0.22.0}/agentshim/core/env.py +0 -0
  24. {agentshim-0.21.0 → agentshim-0.22.0}/agentshim/core/errors.py +0 -0
  25. {agentshim-0.21.0 → agentshim-0.22.0}/agentshim/core/events.py +0 -0
  26. {agentshim-0.21.0 → agentshim-0.22.0}/agentshim/core/home.py +0 -0
  27. {agentshim-0.21.0 → agentshim-0.22.0}/agentshim/core/ids.py +0 -0
  28. {agentshim-0.21.0 → agentshim-0.22.0}/agentshim/core/mcp.py +0 -0
  29. {agentshim-0.21.0 → agentshim-0.22.0}/agentshim/core/models.py +0 -0
  30. {agentshim-0.21.0 → agentshim-0.22.0}/agentshim/core/permissions.py +0 -0
  31. {agentshim-0.21.0 → agentshim-0.22.0}/agentshim/core/plugin.py +0 -0
  32. {agentshim-0.21.0 → agentshim-0.22.0}/agentshim/core/pricing.py +0 -0
  33. {agentshim-0.21.0 → agentshim-0.22.0}/agentshim/core/profile.py +0 -0
  34. {agentshim-0.21.0 → agentshim-0.22.0}/agentshim/core/provider.py +0 -0
  35. {agentshim-0.21.0 → agentshim-0.22.0}/agentshim/core/schema.py +0 -0
  36. {agentshim-0.21.0 → agentshim-0.22.0}/agentshim/core/session_policy.py +0 -0
  37. {agentshim-0.21.0 → agentshim-0.22.0}/agentshim/core/skills.py +0 -0
  38. {agentshim-0.21.0 → agentshim-0.22.0}/agentshim/core/status.py +0 -0
  39. {agentshim-0.21.0 → agentshim-0.22.0}/agentshim/core/stream.py +0 -0
  40. {agentshim-0.21.0 → agentshim-0.22.0}/agentshim/core/turn.py +0 -0
  41. {agentshim-0.21.0 → agentshim-0.22.0}/agentshim/execution/__init__.py +0 -0
  42. {agentshim-0.21.0 → agentshim-0.22.0}/agentshim/execution/confinement.py +0 -0
  43. {agentshim-0.21.0 → agentshim-0.22.0}/agentshim/execution/docker.py +0 -0
  44. {agentshim-0.21.0 → agentshim-0.22.0}/agentshim/execution/executor.py +0 -0
  45. {agentshim-0.21.0 → agentshim-0.22.0}/agentshim/execution/host.py +0 -0
  46. {agentshim-0.21.0 → agentshim-0.22.0}/agentshim/execution/process.py +0 -0
  47. {agentshim-0.21.0 → agentshim-0.22.0}/agentshim/execution/readiness.py +0 -0
  48. {agentshim-0.21.0 → agentshim-0.22.0}/agentshim/execution/transform.py +0 -0
  49. {agentshim-0.21.0 → agentshim-0.22.0}/agentshim/models.py +0 -0
  50. {agentshim-0.21.0 → agentshim-0.22.0}/agentshim/oneshot.py +0 -0
  51. {agentshim-0.21.0 → agentshim-0.22.0}/agentshim/probe.py +0 -0
  52. {agentshim-0.21.0 → agentshim-0.22.0}/agentshim/providers/__init__.py +0 -0
  53. {agentshim-0.21.0 → agentshim-0.22.0}/agentshim/providers/_testing.py +0 -0
  54. {agentshim-0.21.0 → agentshim-0.22.0}/agentshim/providers/catalog.py +0 -0
  55. {agentshim-0.21.0 → agentshim-0.22.0}/agentshim/providers/claude/__init__.py +0 -0
  56. {agentshim-0.21.0 → agentshim-0.22.0}/agentshim/providers/claude/failures.py +0 -0
  57. {agentshim-0.21.0 → agentshim-0.22.0}/agentshim/providers/claude/hooks/__init__.py +0 -0
  58. {agentshim-0.21.0 → agentshim-0.22.0}/agentshim/providers/claude/hooks/confine_reads.py +0 -0
  59. {agentshim-0.21.0 → agentshim-0.22.0}/agentshim/providers/claude/plugin.py +0 -0
  60. {agentshim-0.21.0 → agentshim-0.22.0}/agentshim/providers/claude/probe.py +0 -0
  61. {agentshim-0.21.0 → agentshim-0.22.0}/agentshim/providers/claude/provider.py +0 -0
  62. {agentshim-0.21.0 → agentshim-0.22.0}/agentshim/providers/claude/rate_limits.py +0 -0
  63. {agentshim-0.21.0 → agentshim-0.22.0}/agentshim/providers/claude/sandbox.py +0 -0
  64. {agentshim-0.21.0 → agentshim-0.22.0}/agentshim/providers/claude/scripted.py +0 -0
  65. {agentshim-0.21.0 → agentshim-0.22.0}/agentshim/providers/claude/stream_argv.py +0 -0
  66. {agentshim-0.21.0 → agentshim-0.22.0}/agentshim/providers/claude/testing.py +0 -0
  67. {agentshim-0.21.0 → agentshim-0.22.0}/agentshim/providers/claude/tools.py +0 -0
  68. {agentshim-0.21.0 → agentshim-0.22.0}/agentshim/providers/claude/user_hooks.py +0 -0
  69. {agentshim-0.21.0 → agentshim-0.22.0}/agentshim/providers/codex/__init__.py +0 -0
  70. {agentshim-0.21.0 → agentshim-0.22.0}/agentshim/providers/codex/_toml.py +0 -0
  71. {agentshim-0.21.0 → agentshim-0.22.0}/agentshim/providers/codex/app_server/__init__.py +0 -0
  72. {agentshim-0.21.0 → agentshim-0.22.0}/agentshim/providers/codex/app_server/_wire.py +0 -0
  73. {agentshim-0.21.0 → agentshim-0.22.0}/agentshim/providers/codex/app_server/approvals.py +0 -0
  74. {agentshim-0.21.0 → agentshim-0.22.0}/agentshim/providers/codex/app_server/channel.py +0 -0
  75. {agentshim-0.21.0 → agentshim-0.22.0}/agentshim/providers/codex/app_server/errors.py +0 -0
  76. {agentshim-0.21.0 → agentshim-0.22.0}/agentshim/providers/codex/app_server/items.py +0 -0
  77. {agentshim-0.21.0 → agentshim-0.22.0}/agentshim/providers/codex/app_server/mcp.py +0 -0
  78. {agentshim-0.21.0 → agentshim-0.22.0}/agentshim/providers/codex/app_server/permissions.py +0 -0
  79. {agentshim-0.21.0 → agentshim-0.22.0}/agentshim/providers/codex/app_server/profile.py +0 -0
  80. {agentshim-0.21.0 → agentshim-0.22.0}/agentshim/providers/codex/app_server/protocol.py +0 -0
  81. {agentshim-0.21.0 → agentshim-0.22.0}/agentshim/providers/codex/app_server/rate_limits.py +0 -0
  82. {agentshim-0.21.0 → agentshim-0.22.0}/agentshim/providers/codex/app_server/usage.py +0 -0
  83. {agentshim-0.21.0 → agentshim-0.22.0}/agentshim/providers/codex/events.py +0 -0
  84. {agentshim-0.21.0 → agentshim-0.22.0}/agentshim/providers/codex/failures.py +0 -0
  85. {agentshim-0.21.0 → agentshim-0.22.0}/agentshim/providers/codex/mcp_scope.py +0 -0
  86. {agentshim-0.21.0 → agentshim-0.22.0}/agentshim/providers/codex/model_data.py +0 -0
  87. {agentshim-0.21.0 → agentshim-0.22.0}/agentshim/providers/codex/parser.py +0 -0
  88. {agentshim-0.21.0 → agentshim-0.22.0}/agentshim/providers/codex/plugin.py +0 -0
  89. {agentshim-0.21.0 → agentshim-0.22.0}/agentshim/providers/codex/probe.py +0 -0
  90. {agentshim-0.21.0 → agentshim-0.22.0}/agentshim/providers/codex/provider.py +0 -0
  91. {agentshim-0.21.0 → agentshim-0.22.0}/agentshim/providers/codex/rules.py +0 -0
  92. {agentshim-0.21.0 → agentshim-0.22.0}/agentshim/providers/codex/sandbox.py +0 -0
  93. {agentshim-0.21.0 → agentshim-0.22.0}/agentshim/providers/codex/scripted.py +0 -0
  94. {agentshim-0.21.0 → agentshim-0.22.0}/agentshim/providers/codex/skills.py +0 -0
  95. {agentshim-0.21.0 → agentshim-0.22.0}/agentshim/providers/codex/testing.py +0 -0
  96. {agentshim-0.21.0 → agentshim-0.22.0}/agentshim/providers/copilot/__init__.py +0 -0
  97. {agentshim-0.21.0 → agentshim-0.22.0}/agentshim/providers/copilot/events.py +0 -0
  98. {agentshim-0.21.0 → agentshim-0.22.0}/agentshim/providers/copilot/parser.py +0 -0
  99. {agentshim-0.21.0 → agentshim-0.22.0}/agentshim/providers/copilot/plugin.py +0 -0
  100. {agentshim-0.21.0 → agentshim-0.22.0}/agentshim/providers/copilot/probe.py +0 -0
  101. {agentshim-0.21.0 → agentshim-0.22.0}/agentshim/providers/copilot/provider.py +0 -0
  102. {agentshim-0.21.0 → agentshim-0.22.0}/agentshim/providers/copilot/scripted.py +0 -0
  103. {agentshim-0.21.0 → agentshim-0.22.0}/agentshim/providers/copilot/testing.py +0 -0
  104. {agentshim-0.21.0 → agentshim-0.22.0}/agentshim/providers/gemini/__init__.py +0 -0
  105. {agentshim-0.21.0 → agentshim-0.22.0}/agentshim/providers/gemini/events.py +0 -0
  106. {agentshim-0.21.0 → agentshim-0.22.0}/agentshim/providers/gemini/parser.py +0 -0
  107. {agentshim-0.21.0 → agentshim-0.22.0}/agentshim/providers/gemini/plugin.py +0 -0
  108. {agentshim-0.21.0 → agentshim-0.22.0}/agentshim/providers/gemini/probe.py +0 -0
  109. {agentshim-0.21.0 → agentshim-0.22.0}/agentshim/providers/gemini/provider.py +0 -0
  110. {agentshim-0.21.0 → agentshim-0.22.0}/agentshim/providers/gemini/scripted.py +0 -0
  111. {agentshim-0.21.0 → agentshim-0.22.0}/agentshim/providers/gemini/testing.py +0 -0
  112. {agentshim-0.21.0 → agentshim-0.22.0}/agentshim/providers/opencode/__init__.py +0 -0
  113. {agentshim-0.21.0 → agentshim-0.22.0}/agentshim/providers/opencode/events.py +0 -0
  114. {agentshim-0.21.0 → agentshim-0.22.0}/agentshim/providers/opencode/model_data.py +0 -0
  115. {agentshim-0.21.0 → agentshim-0.22.0}/agentshim/providers/opencode/parser.py +0 -0
  116. {agentshim-0.21.0 → agentshim-0.22.0}/agentshim/providers/opencode/plugin.py +0 -0
  117. {agentshim-0.21.0 → agentshim-0.22.0}/agentshim/providers/opencode/probe.py +0 -0
  118. {agentshim-0.21.0 → agentshim-0.22.0}/agentshim/providers/opencode/provider.py +0 -0
  119. {agentshim-0.21.0 → agentshim-0.22.0}/agentshim/providers/opencode/scripted.py +0 -0
  120. {agentshim-0.21.0 → agentshim-0.22.0}/agentshim/providers/opencode/testing.py +0 -0
  121. {agentshim-0.21.0 → agentshim-0.22.0}/agentshim/providers/static_plugin.py +0 -0
  122. {agentshim-0.21.0 → agentshim-0.22.0}/agentshim/py.typed +0 -0
  123. {agentshim-0.21.0 → agentshim-0.22.0}/agentshim/runtime.py +0 -0
  124. {agentshim-0.21.0 → agentshim-0.22.0}/agentshim/session.py +0 -0
  125. {agentshim-0.21.0 → agentshim-0.22.0}/agentshim/testing/__init__.py +0 -0
  126. {agentshim-0.21.0 → agentshim-0.22.0}/agentshim/testing/claude_stream.py +0 -0
  127. {agentshim-0.21.0 → agentshim-0.22.0}/agentshim/testing/clock.py +0 -0
  128. {agentshim-0.21.0 → agentshim-0.22.0}/agentshim/testing/codex_app_server.py +0 -0
  129. {agentshim-0.21.0 → agentshim-0.22.0}/agentshim/testing/confinement.py +0 -0
  130. {agentshim-0.21.0 → agentshim-0.22.0}/agentshim/testing/contracts.py +0 -0
  131. {agentshim-0.21.0 → agentshim-0.22.0}/agentshim/testing/conversation.py +0 -0
  132. {agentshim-0.21.0 → agentshim-0.22.0}/agentshim/testing/probe.py +0 -0
  133. {agentshim-0.21.0 → agentshim-0.22.0}/agentshim/testing/process.py +0 -0
  134. {agentshim-0.21.0 → agentshim-0.22.0}/tests/fixtures/claude_stream/README.md +0 -0
  135. {agentshim-0.21.0 → agentshim-0.22.0}/tests/fixtures/codex/README.md +0 -0
  136. {agentshim-0.21.0 → agentshim-0.22.0}/tests/fixtures/codex_app_server/README.md +0 -0
@@ -1,5 +1,34 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.22.0 (2026-10-19)
4
+
5
+ ### Fixed
6
+
7
+ - A Claude turn's cost is priced from the per-model token counts in the CLI's `modelUsage` and
8
+ agentshim's own price table, not taken from the CLI's `total_cost_usd`. A Claude Code build older
9
+ than the model it runs does not know the model and bills it at a fallback rate (Claude Code
10
+ 2.1.268 priced `claude-sonnet-5-5` about 2.6 times too high). `modelUsage` is cumulative over the
11
+ process, so a long-lived `ClaudeStreamTransport` prices each turn by the difference. A turn that
12
+ used a model with no table price has `cost_usd=None`; no price is guessed. Only a result with no
13
+ `modelUsage` keeps the CLI's figure.
14
+
15
+ ### Added
16
+
17
+ - `CostSource` (`PRICE_TABLE`, `PROVIDER_REPORTED`, `UNKNOWN_MODEL`) and
18
+ `ProviderUsage.cost_source` say where `total_cost_usd` came from; `None` when no cost was
19
+ reported. The Codex app-server transport reports `PRICE_TABLE`. `to_dict()` gains `cost_source`.
20
+ - `ClaudeStreamParser` takes `model_usage_baseline` and `pricing`, and exposes
21
+ `model_usage_total` for the next turn's baseline.
22
+
23
+ ## 0.21.1 (2026-10-19)
24
+
25
+ ### Fixed
26
+
27
+ - Claude Sonnet 5.5, Claude Haiku 5.5, Claude Opus 4.7 and Claude Opus 4.6 have prices, so
28
+ `price_for` and cost lookups no longer come back empty for them. Haiku 5.5 carries its
29
+ prompts-up-to-100,000-tokens prices; its higher over-100,000-token prices are in the entry
30
+ `note`. Every Claude model in `default_models()` now has a price (tested).
31
+
3
32
  ## 0.21.0 (2026-10-18)
4
33
 
5
34
  ### Added
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: agentshim
3
- Version: 0.21.0
3
+ Version: 0.22.0
4
4
  Summary: Provider-agnostic coding-agent CLI shims
5
5
  License-Expression: MIT
6
6
  License-File: LICENSE
@@ -36,6 +36,7 @@ from .core import (
36
36
  ContinuityError,
37
37
  Conversation,
38
38
  ConversationSpec,
39
+ CostSource,
39
40
  CredentialKind,
40
41
  CredentialSource,
41
42
  EndpointSpec,
@@ -185,7 +186,7 @@ from .providers.opencode import OpencodeProvider
185
186
  from .runtime import Agent
186
187
  from .session import Session, Turn, TurnTicket, map_mcp_servers
187
188
 
188
- __version__ = "0.21.0"
189
+ __version__ = "0.22.0"
189
190
 
190
191
  __all__ = [ # noqa: RUF022 - public API test uses Python's case-sensitive order
191
192
  "Agent",
@@ -234,6 +235,7 @@ __all__ = [ # noqa: RUF022 - public API test uses Python's case-sensitive order
234
235
  "Conversation",
235
236
  "ConversationSpec",
236
237
  "CopilotProvider",
238
+ "CostSource",
237
239
  "CredentialKind",
238
240
  "CredentialSource",
239
241
  "DEFAULT_CATALOG",
@@ -126,7 +126,7 @@ from .skills import SkillSummary, SkillTracker
126
126
  from .status import AuthState, ProviderStatus
127
127
  from .stream import ToolTracker, parse_json_object
128
128
  from .turn import OutputSchema, TurnRequest, TurnResult
129
- from .usage import ProviderUsage, TokenUsage, TokenWeights, normalized_usage
129
+ from .usage import CostSource, ProviderUsage, TokenUsage, TokenWeights, normalized_usage
130
130
 
131
131
  __all__ = [
132
132
  "AgentEvent",
@@ -157,6 +157,7 @@ __all__ = [
157
157
  "ContinuityError",
158
158
  "Conversation",
159
159
  "ConversationSpec",
160
+ "CostSource",
160
161
  "CredentialKind",
161
162
  "CredentialSource",
162
163
  "EndpointSpec",
@@ -20,6 +20,7 @@ from __future__ import annotations
20
20
 
21
21
  import math
22
22
  from dataclasses import dataclass, field
23
+ from enum import Enum
23
24
  from typing import TYPE_CHECKING, Any
24
25
 
25
26
  if TYPE_CHECKING:
@@ -261,6 +262,17 @@ def normalized_usage( # noqa: PLR0913 - one keyword per normalized count
261
262
  )
262
263
 
263
264
 
265
+ class CostSource(str, Enum):
266
+ """Where a reported cost came from."""
267
+
268
+ #: Computed from the turn's token counts and agentshim's price table.
269
+ PRICE_TABLE = "price_table"
270
+ #: The CLI's own figure, used only when it did not say which model ran.
271
+ PROVIDER_REPORTED = "provider_reported"
272
+ #: The turn used a model agentshim has no price for, so there is no cost.
273
+ UNKNOWN_MODEL = "unknown_model"
274
+
275
+
264
276
  @dataclass(frozen=True)
265
277
  class ProviderUsage:
266
278
  """Normalized token counts plus the raw provider mapping.
@@ -278,6 +290,9 @@ class ProviderUsage:
278
290
  provider: str = ""
279
291
  raw: Mapping[str, Any] | None = None
280
292
  increment_known: bool = True
293
+ #: Where ``total_cost_usd`` came from; ``None`` when no cost was reported.
294
+ #: ``UNKNOWN_MODEL`` goes with a ``None`` cost: no price is guessed.
295
+ cost_source: CostSource | None = None
281
296
 
282
297
  def to_dict(self) -> dict[str, Any]:
283
298
  """Flatten the normalized counts, cost, and provider name into one mapping.
@@ -290,4 +305,5 @@ class ProviderUsage:
290
305
  "total_cost_usd": self.total_cost_usd,
291
306
  "provider": self.provider,
292
307
  "increment_known": self.increment_known,
308
+ "cost_source": None if self.cost_source is None else self.cost_source.value,
293
309
  }
@@ -106,4 +106,4 @@ def default_pricing(*, catalog: ProviderCatalog | None = None) -> PricingTable:
106
106
  by_key[entry.key] = entry
107
107
  entries = tuple(by_key.values())
108
108
  updated = max((entry.checked for entry in entries), default=dt.date(2026, 9, 27))
109
- return PricingTable(version="2026-09-27", last_updated=updated, entries=entries)
109
+ return PricingTable(version="2026-10-19", last_updated=updated, entries=entries)
@@ -0,0 +1,140 @@
1
+ """Price a Claude turn from the CLI's per-model token counts.
2
+
3
+ The CLI's own ``total_cost_usd`` is priced by the CLI's built-in table. A CLI
4
+ older than the model it runs does not know the model and bills it at a
5
+ fallback rate, so that figure can be several times too high. The ``modelUsage``
6
+ mapping of a ``result`` frame carries the token counts per model, which
7
+ agentshim prices with its own table instead.
8
+
9
+ ``modelUsage`` is cumulative over the CLI process, like ``total_cost_usd``, so
10
+ a turn's tokens are the difference from the previous result's counts. It does
11
+ not split cache writes by TTL; the turn's ``usage.cache_creation`` does, and
12
+ its share of one-hour writes is applied to every model's writes.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ from dataclasses import dataclass
18
+ from typing import TYPE_CHECKING, Any, cast
19
+
20
+ from agentshim.core.pricing import cost_usd
21
+ from agentshim.core.usage import CostSource, TokenUsage, normalized_usage
22
+ from agentshim.pricing import price_for
23
+
24
+ if TYPE_CHECKING:
25
+ from collections.abc import Mapping
26
+
27
+ from agentshim.core.pricing import PricingTable
28
+
29
+ PROVIDER_NAME = "claude"
30
+
31
+
32
+ @dataclass(frozen=True)
33
+ class ModelCounts:
34
+ """One model's cumulative token counts as ``modelUsage`` reports them.
35
+
36
+ ``input_tokens`` is uncached input, disjoint from the cache counts, as in
37
+ the CLI's own report.
38
+ """
39
+
40
+ input_tokens: int = 0
41
+ output_tokens: int = 0
42
+ cache_read_tokens: int = 0
43
+ cache_write_tokens: int = 0
44
+ #: The id the CLI says the model canonically is, when it says so.
45
+ canonical: str | None = None
46
+
47
+ def since(self, baseline: ModelCounts | None) -> ModelCounts:
48
+ """The counts accrued after *baseline*; a count that went down reads as 0."""
49
+ if baseline is None:
50
+ return self
51
+ return ModelCounts(
52
+ input_tokens=max(self.input_tokens - baseline.input_tokens, 0),
53
+ output_tokens=max(self.output_tokens - baseline.output_tokens, 0),
54
+ cache_read_tokens=max(self.cache_read_tokens - baseline.cache_read_tokens, 0),
55
+ cache_write_tokens=max(self.cache_write_tokens - baseline.cache_write_tokens, 0),
56
+ canonical=self.canonical,
57
+ )
58
+
59
+ @property
60
+ def empty(self) -> bool:
61
+ """Whether no token was counted."""
62
+ return not (
63
+ self.input_tokens
64
+ or self.output_tokens
65
+ or self.cache_read_tokens
66
+ or self.cache_write_tokens
67
+ )
68
+
69
+
70
+ @dataclass(frozen=True)
71
+ class TurnCost:
72
+ """A turn's cost and where it came from; ``usd`` is ``None`` for an unknown model."""
73
+
74
+ usd: float | None
75
+ source: CostSource
76
+
77
+
78
+ def parse_model_usage(raw: object) -> dict[str, ModelCounts] | None:
79
+ """Read a ``result`` frame's ``modelUsage``; ``None`` when it is absent or malformed."""
80
+ if not isinstance(raw, dict):
81
+ return None
82
+ parsed: dict[str, ModelCounts] = {}
83
+ for model, counts in cast("dict[object, object]", raw).items():
84
+ if not isinstance(model, str) or not isinstance(counts, dict):
85
+ return None
86
+ fields = cast("Mapping[str, Any]", counts)
87
+ canonical = fields.get("canonicalModel")
88
+ parsed[model] = ModelCounts(
89
+ input_tokens=_count(fields, "inputTokens"),
90
+ output_tokens=_count(fields, "outputTokens"),
91
+ cache_read_tokens=_count(fields, "cacheReadInputTokens"),
92
+ cache_write_tokens=_count(fields, "cacheCreationInputTokens"),
93
+ canonical=canonical if isinstance(canonical, str) else None,
94
+ )
95
+ return parsed
96
+
97
+
98
+ def priced_turn(
99
+ model_usage: Mapping[str, ModelCounts],
100
+ baseline: Mapping[str, ModelCounts],
101
+ *,
102
+ one_hour_share: float,
103
+ table: PricingTable | None = None,
104
+ ) -> TurnCost:
105
+ """Price one turn's tokens, each model at its own table price.
106
+
107
+ *one_hour_share* is the fraction of cache writes billed at the one-hour
108
+ rate. A model with tokens in the turn and no price makes the whole turn
109
+ ``UNKNOWN_MODEL`` with no cost: a partial sum would read as complete.
110
+ """
111
+ total = 0.0
112
+ for model, counts in model_usage.items():
113
+ delta = counts.since(baseline.get(model))
114
+ if delta.empty:
115
+ continue
116
+ pricing = price_for(PROVIDER_NAME, model, table)
117
+ if pricing is None and delta.canonical is not None:
118
+ pricing = price_for(PROVIDER_NAME, delta.canonical, table)
119
+ if pricing is None:
120
+ return TurnCost(None, CostSource.UNKNOWN_MODEL)
121
+ total += cost_usd(_tokens(delta, one_hour_share), pricing)
122
+ return TurnCost(total, CostSource.PRICE_TABLE)
123
+
124
+
125
+ def _tokens(counts: ModelCounts, one_hour_share: float) -> TokenUsage:
126
+ share = min(max(one_hour_share, 0.0), 1.0)
127
+ return normalized_usage(
128
+ input_tokens=counts.input_tokens + counts.cache_read_tokens + counts.cache_write_tokens,
129
+ output_tokens=counts.output_tokens,
130
+ cache_read_input_tokens=counts.cache_read_tokens,
131
+ cache_write_input_tokens=counts.cache_write_tokens,
132
+ cache_write_1h_input_tokens=round(counts.cache_write_tokens * share),
133
+ )
134
+
135
+
136
+ def _count(counts: Mapping[str, Any], key: str) -> int:
137
+ value = counts.get(key)
138
+ if isinstance(value, bool) or not isinstance(value, (int, float)):
139
+ return 0
140
+ return max(int(value), 0)
@@ -10,6 +10,7 @@ import json
10
10
  from dataclasses import dataclass
11
11
  from typing import TYPE_CHECKING, Any, cast
12
12
 
13
+ from .cost import ModelCounts, parse_model_usage
13
14
  from .rate_limits import RateLimitFrame, parse_rate_limit
14
15
 
15
16
  if TYPE_CHECKING:
@@ -86,6 +87,8 @@ class ResultFrame:
86
87
  num_turns: int | None = None
87
88
  usage: Mapping[str, Any] | None = None
88
89
  total_cost_usd: float | None = None
90
+ #: Per-model cumulative token counts (the CLI's ``modelUsage``); ``None`` when absent.
91
+ model_usage: Mapping[str, ModelCounts] | None = None
89
92
  duration_ms: int | None = None
90
93
  is_error: bool = False
91
94
  subtype: str | None = None
@@ -126,6 +129,7 @@ def parse_frame(data: Mapping[str, Any]) -> ClaudeFrame | None:
126
129
  num_turns=_int_or_none(data.get("num_turns")),
127
130
  usage=_mapping_or_none(data.get("usage")),
128
131
  total_cost_usd=_float_or_none(data.get("total_cost_usd")),
132
+ model_usage=parse_model_usage(data.get("modelUsage")),
129
133
  duration_ms=_int_or_none(data.get("duration_ms")),
130
134
  is_error=bool(data.get("is_error")),
131
135
  subtype=_str_or_none(data.get("subtype")),
@@ -10,7 +10,7 @@ ANTHROPIC_CONTEXT_WINDOWS_URL = (
10
10
  )
11
11
  ANTHROPIC_PRICING_URL = "https://platform.claude.com/docs/en/about-claude/pricing"
12
12
  _MODEL_CHECKED = dt.date(2026, 10, 17)
13
- _PRICE_CHECKED = dt.date(2026, 9, 27)
13
+ _PRICE_CHECKED = dt.date(2026, 10, 19)
14
14
 
15
15
 
16
16
  def _anthropic_model(
@@ -61,6 +61,9 @@ MODEL_ENTRIES = (
61
61
  )
62
62
 
63
63
 
64
+ _NOTE = "Claude API first-party, global inference; thinking is billed as output."
65
+
66
+
64
67
  def _anthropic( # noqa: PLR0913 - one positional per price column
65
68
  model: str,
66
69
  input_usd: float,
@@ -68,6 +71,7 @@ def _anthropic( # noqa: PLR0913 - one positional per price column
68
71
  cache_write_1h_usd: float,
69
72
  cache_read_usd: float,
70
73
  output_usd: float,
74
+ note: str = "",
71
75
  ) -> ModelPricing:
72
76
  return ModelPricing(
73
77
  vendor="anthropic",
@@ -79,7 +83,7 @@ def _anthropic( # noqa: PLR0913 - one positional per price column
79
83
  output_usd=output_usd,
80
84
  source=ANTHROPIC_PRICING_URL,
81
85
  checked=_PRICE_CHECKED,
82
- note="Claude API first-party, global inference; thinking is billed as output.",
86
+ note=f"{_NOTE} {note}".strip(),
83
87
  )
84
88
 
85
89
 
@@ -89,7 +93,23 @@ PRICING_ENTRIES = (
89
93
  _anthropic("claude-opus-5-5", 4.00, 5.00, 8.00, 0.20, 20.00),
90
94
  _anthropic("claude-opus-5", 5.00, 6.25, 10.00, 0.50, 25.00),
91
95
  _anthropic("claude-opus-4-8", 5.00, 6.25, 10.00, 0.50, 25.00),
96
+ _anthropic("claude-opus-4-7", 5.00, 6.25, 10.00, 0.50, 25.00),
97
+ _anthropic("claude-opus-4-6", 5.00, 6.25, 10.00, 0.50, 25.00),
98
+ _anthropic("claude-sonnet-5-5", 2.00, 2.50, 4.00, 0.10, 10.00),
92
99
  _anthropic("claude-sonnet-5", 2.00, 2.50, 4.00, 0.20, 10.00),
93
100
  _anthropic("claude-sonnet-4-6", 3.00, 3.75, 6.00, 0.30, 15.00),
101
+ _anthropic(
102
+ "claude-haiku-5-5",
103
+ 0.10,
104
+ 0.125,
105
+ 0.20,
106
+ 0.01,
107
+ 0.50,
108
+ note=(
109
+ "Prices for prompts up to 100,000 tokens; a request whose prompt is over "
110
+ "100,000 tokens pays 0.50 input, 0.625 cache write, 1.00 1h cache write, "
111
+ "0.05 cache read and 2.50 output, which this table leaves out."
112
+ ),
113
+ ),
94
114
  _anthropic("claude-haiku-4-5", 1.00, 1.25, 2.00, 0.10, 5.00),
95
115
  )
@@ -21,8 +21,9 @@ from agentshim.core.events import (
21
21
  )
22
22
  from agentshim.core.provider import ParsedTurn
23
23
  from agentshim.core.stream import ToolTracker, parse_json_object
24
- from agentshim.core.usage import ProviderUsage, TokenUsage, normalized_usage
24
+ from agentshim.core.usage import CostSource, ProviderUsage, TokenUsage, normalized_usage
25
25
 
26
+ from .cost import ModelCounts, priced_turn
26
27
  from .events import (
27
28
  AssistantMessage,
28
29
  ResultFrame,
@@ -40,6 +41,7 @@ if TYPE_CHECKING:
40
41
 
41
42
  from agentshim.core.errors import FailureKind
42
43
  from agentshim.core.events import AgentEvent
44
+ from agentshim.core.pricing import PricingTable
43
45
 
44
46
  PROVIDER_NAME = "claude"
45
47
 
@@ -128,6 +130,8 @@ class ClaudeStreamParser:
128
130
  *,
129
131
  expect_structured: bool = False,
130
132
  cost_baseline_usd: float = 0.0,
133
+ model_usage_baseline: Mapping[str, ModelCounts] | None = None,
134
+ pricing: PricingTable | None = None,
131
135
  ) -> None:
132
136
  """Start a parser for one run.
133
137
 
@@ -137,9 +141,20 @@ class ClaudeStreamParser:
137
141
  total its previous turn ended at as *cost_baseline_usd* and this
138
142
  parser reports the turn's own cost (``total_cost_usd`` then still
139
143
  holds the cumulative figure).
144
+
145
+ The turn's cost is priced from the result's ``modelUsage`` with the
146
+ agentshim price table (*pricing*, or the default one), never from the
147
+ CLI's ``total_cost_usd`` when the models are known. ``modelUsage`` is
148
+ cumulative too: pass the previous turn's ``model_usage_total`` as
149
+ *model_usage_baseline*. Only a result without ``modelUsage`` falls
150
+ back to the CLI's figure.
140
151
  """
141
152
  self._cost_baseline = cost_baseline_usd
153
+ self._model_usage_baseline: Mapping[str, ModelCounts] = model_usage_baseline or {}
154
+ self._pricing = pricing
142
155
  self.total_cost_usd: float | None = None
156
+ #: The cumulative per-model counts the last result reported.
157
+ self.model_usage_total: Mapping[str, ModelCounts] | None = None
143
158
  self._emit = emit
144
159
  self._expect_structured = expect_structured
145
160
  self._tools = ToolTracker()
@@ -150,6 +165,7 @@ class ClaudeStreamParser:
150
165
  self._usage = ProviderUsage(provider=PROVIDER_NAME)
151
166
  self._results = 0
152
167
  self._cost_usd: float | None = None
168
+ self._cost_source: CostSource | None = None
153
169
  self._error: str | None = None
154
170
  # What the stream said about a failed API request, for classifying it.
155
171
  self._api_error: str | None = None
@@ -278,11 +294,9 @@ class ClaudeStreamParser:
278
294
  def _result(self, frame: ResultFrame) -> None:
279
295
  self._final_text = frame.text
280
296
  self.total_cost_usd = frame.total_cost_usd
281
- self._cost_usd = (
282
- None if frame.total_cost_usd is None else frame.total_cost_usd - self._cost_baseline
283
- )
284
- self._structured = self._structured_payload(frame)
285
297
  tokens = fold_usage(frame.usage, turns=frame.num_turns or 0)
298
+ self._price(frame, tokens)
299
+ self._structured = self._structured_payload(frame)
286
300
  if self._results:
287
301
  # A steered message can start a follow-on turn inside this run; its
288
302
  # ``result`` reports only its own tokens, so the run sums them.
@@ -293,6 +307,7 @@ class ClaudeStreamParser:
293
307
  total_cost_usd=self._cost_usd,
294
308
  provider=PROVIDER_NAME,
295
309
  raw=frame.usage,
310
+ cost_source=self._cost_source,
296
311
  )
297
312
  self._emit(UsageReport(self._usage, self._cost_usd))
298
313
  if frame.is_error:
@@ -301,6 +316,27 @@ class ClaudeStreamParser:
301
316
  self._error = self._error_text(frame)
302
317
  self._emit(ProviderError(self._error))
303
318
 
319
+ def _price(self, frame: ResultFrame, tokens: TokenUsage) -> None:
320
+ """Set the turn's cost from its per-model tokens, or from the CLI's when it names none."""
321
+ if frame.model_usage is not None:
322
+ self.model_usage_total = frame.model_usage
323
+ writes = tokens.cache_write_input_tokens
324
+ share = tokens.cache_write_1h_input_tokens / writes if writes else 0.0
325
+ priced = priced_turn(
326
+ frame.model_usage,
327
+ self._model_usage_baseline,
328
+ one_hour_share=share,
329
+ table=self._pricing,
330
+ )
331
+ self._cost_usd = priced.usd
332
+ self._cost_source = priced.source
333
+ elif frame.total_cost_usd is not None:
334
+ self._cost_usd = frame.total_cost_usd - self._cost_baseline
335
+ self._cost_source = CostSource.PROVIDER_REPORTED
336
+ else:
337
+ self._cost_usd = None
338
+ self._cost_source = None
339
+
304
340
  def _structured_payload(self, frame: ResultFrame) -> object | None:
305
341
  # Only a turn that asked for a schema gets a payload: an unrequested
306
342
  # ``structured_output`` field must never replace the prose answer.
@@ -10,8 +10,9 @@ in ``tests/fixtures/claude_stream``):
10
10
 
11
11
  * ``system/init`` is repeated at the start of every turn, so readiness is the
12
12
  reply to the ``initialize`` request, not ``init``.
13
- * ``total_cost_usd`` is cumulative over the process while ``usage`` is per turn,
14
- so the cost of a turn is the difference from the previous result.
13
+ * ``total_cost_usd`` and ``modelUsage`` are cumulative over the process while
14
+ ``usage`` is per turn, so the cost of a turn is the difference from the
15
+ previous result. The cost is priced from ``modelUsage`` (see ``cost.py``).
15
16
  * A failed API request arrives as a ``result`` with subtype ``success`` and
16
17
  ``is_error`` true; a failure is classified on ``is_error`` first.
17
18
  * ``--json-schema``, MCP servers, effort and extra arguments are flags of the
@@ -99,6 +100,7 @@ if TYPE_CHECKING:
99
100
  from agentshim.execution.executor import CommandExecutor
100
101
  from agentshim.execution.process import Process, ProcessOutput
101
102
 
103
+ from .cost import ModelCounts
102
104
  from .user_hooks import ClaudeHook
103
105
 
104
106
  #: How often an idle pull loop looks at the clock again.
@@ -288,6 +290,7 @@ class _StreamConversation:
288
290
  self._argv: tuple[str, ...] = ()
289
291
  self._config = ProcessConfig()
290
292
  self._cost_baseline = 0.0
293
+ self._model_usage_baseline: Mapping[str, ModelCounts] = {}
291
294
  self._stderr_tail: deque[str] = _lines()
292
295
  self._closed = False
293
296
  self._running = False
@@ -414,6 +417,7 @@ class _StreamConversation:
414
417
  relay,
415
418
  expect_structured=request.output_schema is not None,
416
419
  cost_baseline_usd=self._cost_baseline,
420
+ model_usage_baseline=self._model_usage_baseline,
417
421
  )
418
422
  deadline = None if request.timeout is None else started + request.timeout
419
423
  run = _TurnRun(parser, relay, request.timeout, deadline)
@@ -502,6 +506,7 @@ class _StreamConversation:
502
506
  self._argv = tuple(argv)
503
507
  self._config = config
504
508
  self._cost_baseline = 0.0
509
+ self._model_usage_baseline = {}
505
510
  if closed:
506
511
  self._stop(process) # outside the lock: a stop waits, and interrupts need the lock
507
512
  msg = "conversation closed while its process started"
@@ -863,6 +868,8 @@ class _StreamConversation:
863
868
  parsed = parser.finish()
864
869
  if parser.total_cost_usd is not None:
865
870
  self._cost_baseline = parser.total_cost_usd
871
+ if parser.model_usage_total is not None:
872
+ self._model_usage_baseline = parser.model_usage_total
866
873
  self._learn_id(parsed.session_id)
867
874
  self._report_denials(run, frame)
868
875
  if run.expired:
@@ -51,6 +51,7 @@ from agentshim.core.permissions import ApprovalPolicy
51
51
  from agentshim.core.schema import dialect_problems
52
52
  from agentshim.core.skills import SkillTracker
53
53
  from agentshim.core.turn import TurnResult
54
+ from agentshim.core.usage import CostSource
54
55
  from agentshim.execution.host import HostCommandExecutor
55
56
  from agentshim.execution.process import SpawnRequest
56
57
  from agentshim.pricing import cost_usd, price_for
@@ -927,7 +928,11 @@ class _Conversation:
927
928
  usage = turn_usage(turn.total, self._baseline)
928
929
  pricing = self._pricing
929
930
  if usage.increment_known and pricing is not None:
930
- usage = replace(usage, total_cost_usd=cost_usd(usage.tokens, pricing))
931
+ usage = replace(
932
+ usage,
933
+ total_cost_usd=cost_usd(usage.tokens, pricing),
934
+ cost_source=CostSource.PRICE_TABLE,
935
+ )
931
936
  if turn.total is not None:
932
937
  self._baseline = turn.total
933
938
  return usage
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "agentshim"
3
- version = "0.21.0"
3
+ version = "0.22.0"
4
4
  description = "Provider-agnostic coding-agent CLI shims"
5
5
  readme = "README.md"
6
6
  license = "MIT"
File without changes
File without changes
File without changes