agentshim 0.24.0__tar.gz → 0.24.1__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 (149) hide show
  1. {agentshim-0.24.0 → agentshim-0.24.1}/CHANGELOG.md +73 -0
  2. {agentshim-0.24.0 → agentshim-0.24.1}/PKG-INFO +1 -1
  3. {agentshim-0.24.0 → agentshim-0.24.1}/agentshim/__init__.py +1 -1
  4. {agentshim-0.24.0 → agentshim-0.24.1}/agentshim/core/transcripts.py +21 -11
  5. {agentshim-0.24.0 → agentshim-0.24.1}/agentshim/core/usage.py +12 -4
  6. {agentshim-0.24.0 → agentshim-0.24.1}/agentshim/providers/claude/cost.py +35 -6
  7. {agentshim-0.24.0 → agentshim-0.24.1}/agentshim/providers/claude/model_data.py +3 -1
  8. {agentshim-0.24.0 → agentshim-0.24.1}/agentshim/providers/claude/parser.py +14 -13
  9. agentshim-0.24.1/agentshim/providers/claude/transcripts.py +342 -0
  10. {agentshim-0.24.0 → agentshim-0.24.1}/agentshim/providers/codex/app_server/transport.py +1 -1
  11. {agentshim-0.24.0 → agentshim-0.24.1}/agentshim/providers/codex/transcripts.py +10 -1
  12. {agentshim-0.24.0 → agentshim-0.24.1}/agentshim/rates.py +88 -18
  13. {agentshim-0.24.0 → agentshim-0.24.1}/agentshim/transcripts.py +107 -32
  14. {agentshim-0.24.0 → agentshim-0.24.1}/pyproject.toml +1 -1
  15. agentshim-0.24.1/tests/fixtures/claude_session/README.md +19 -0
  16. agentshim-0.24.0/agentshim/providers/claude/transcripts.py +0 -209
  17. {agentshim-0.24.0 → agentshim-0.24.1}/.gitignore +0 -0
  18. {agentshim-0.24.0 → agentshim-0.24.1}/LICENSE +0 -0
  19. {agentshim-0.24.0 → agentshim-0.24.1}/README.md +0 -0
  20. {agentshim-0.24.0 → agentshim-0.24.1}/agentshim/agent.py +0 -0
  21. {agentshim-0.24.0 → agentshim-0.24.1}/agentshim/core/__init__.py +0 -0
  22. {agentshim-0.24.0 → agentshim-0.24.1}/agentshim/core/_files.py +0 -0
  23. {agentshim-0.24.0 → agentshim-0.24.1}/agentshim/core/auth.py +0 -0
  24. {agentshim-0.24.0 → agentshim-0.24.1}/agentshim/core/checkpoints.py +0 -0
  25. {agentshim-0.24.0 → agentshim-0.24.1}/agentshim/core/clock.py +0 -0
  26. {agentshim-0.24.0 → agentshim-0.24.1}/agentshim/core/conversation.py +0 -0
  27. {agentshim-0.24.0 → agentshim-0.24.1}/agentshim/core/env.py +0 -0
  28. {agentshim-0.24.0 → agentshim-0.24.1}/agentshim/core/errors.py +0 -0
  29. {agentshim-0.24.0 → agentshim-0.24.1}/agentshim/core/events.py +0 -0
  30. {agentshim-0.24.0 → agentshim-0.24.1}/agentshim/core/home.py +0 -0
  31. {agentshim-0.24.0 → agentshim-0.24.1}/agentshim/core/ids.py +0 -0
  32. {agentshim-0.24.0 → agentshim-0.24.1}/agentshim/core/mcp.py +0 -0
  33. {agentshim-0.24.0 → agentshim-0.24.1}/agentshim/core/models.py +0 -0
  34. {agentshim-0.24.0 → agentshim-0.24.1}/agentshim/core/permissions.py +0 -0
  35. {agentshim-0.24.0 → agentshim-0.24.1}/agentshim/core/plugin.py +0 -0
  36. {agentshim-0.24.0 → agentshim-0.24.1}/agentshim/core/pricing.py +0 -0
  37. {agentshim-0.24.0 → agentshim-0.24.1}/agentshim/core/profile.py +0 -0
  38. {agentshim-0.24.0 → agentshim-0.24.1}/agentshim/core/provider.py +0 -0
  39. {agentshim-0.24.0 → agentshim-0.24.1}/agentshim/core/rates.py +0 -0
  40. {agentshim-0.24.0 → agentshim-0.24.1}/agentshim/core/schema.py +0 -0
  41. {agentshim-0.24.0 → agentshim-0.24.1}/agentshim/core/session_policy.py +0 -0
  42. {agentshim-0.24.0 → agentshim-0.24.1}/agentshim/core/skills.py +0 -0
  43. {agentshim-0.24.0 → agentshim-0.24.1}/agentshim/core/status.py +0 -0
  44. {agentshim-0.24.0 → agentshim-0.24.1}/agentshim/core/stream.py +0 -0
  45. {agentshim-0.24.0 → agentshim-0.24.1}/agentshim/core/turn.py +0 -0
  46. {agentshim-0.24.0 → agentshim-0.24.1}/agentshim/execution/__init__.py +0 -0
  47. {agentshim-0.24.0 → agentshim-0.24.1}/agentshim/execution/confinement.py +0 -0
  48. {agentshim-0.24.0 → agentshim-0.24.1}/agentshim/execution/docker.py +0 -0
  49. {agentshim-0.24.0 → agentshim-0.24.1}/agentshim/execution/executor.py +0 -0
  50. {agentshim-0.24.0 → agentshim-0.24.1}/agentshim/execution/host.py +0 -0
  51. {agentshim-0.24.0 → agentshim-0.24.1}/agentshim/execution/process.py +0 -0
  52. {agentshim-0.24.0 → agentshim-0.24.1}/agentshim/execution/readiness.py +0 -0
  53. {agentshim-0.24.0 → agentshim-0.24.1}/agentshim/execution/transform.py +0 -0
  54. {agentshim-0.24.0 → agentshim-0.24.1}/agentshim/models.py +0 -0
  55. {agentshim-0.24.0 → agentshim-0.24.1}/agentshim/oneshot.py +0 -0
  56. {agentshim-0.24.0 → agentshim-0.24.1}/agentshim/pricing.py +0 -0
  57. {agentshim-0.24.0 → agentshim-0.24.1}/agentshim/probe.py +0 -0
  58. {agentshim-0.24.0 → agentshim-0.24.1}/agentshim/providers/__init__.py +0 -0
  59. {agentshim-0.24.0 → agentshim-0.24.1}/agentshim/providers/_testing.py +0 -0
  60. {agentshim-0.24.0 → agentshim-0.24.1}/agentshim/providers/catalog.py +0 -0
  61. {agentshim-0.24.0 → agentshim-0.24.1}/agentshim/providers/claude/__init__.py +0 -0
  62. {agentshim-0.24.0 → agentshim-0.24.1}/agentshim/providers/claude/events.py +0 -0
  63. {agentshim-0.24.0 → agentshim-0.24.1}/agentshim/providers/claude/failures.py +0 -0
  64. {agentshim-0.24.0 → agentshim-0.24.1}/agentshim/providers/claude/hooks/__init__.py +0 -0
  65. {agentshim-0.24.0 → agentshim-0.24.1}/agentshim/providers/claude/hooks/confine_reads.py +0 -0
  66. {agentshim-0.24.0 → agentshim-0.24.1}/agentshim/providers/claude/plugin.py +0 -0
  67. {agentshim-0.24.0 → agentshim-0.24.1}/agentshim/providers/claude/prices_generated.py +0 -0
  68. {agentshim-0.24.0 → agentshim-0.24.1}/agentshim/providers/claude/probe.py +0 -0
  69. {agentshim-0.24.0 → agentshim-0.24.1}/agentshim/providers/claude/provider.py +0 -0
  70. {agentshim-0.24.0 → agentshim-0.24.1}/agentshim/providers/claude/rate_limits.py +0 -0
  71. {agentshim-0.24.0 → agentshim-0.24.1}/agentshim/providers/claude/sandbox.py +0 -0
  72. {agentshim-0.24.0 → agentshim-0.24.1}/agentshim/providers/claude/scripted.py +0 -0
  73. {agentshim-0.24.0 → agentshim-0.24.1}/agentshim/providers/claude/stream_argv.py +0 -0
  74. {agentshim-0.24.0 → agentshim-0.24.1}/agentshim/providers/claude/stream_transport.py +0 -0
  75. {agentshim-0.24.0 → agentshim-0.24.1}/agentshim/providers/claude/testing.py +0 -0
  76. {agentshim-0.24.0 → agentshim-0.24.1}/agentshim/providers/claude/tools.py +0 -0
  77. {agentshim-0.24.0 → agentshim-0.24.1}/agentshim/providers/claude/user_hooks.py +0 -0
  78. {agentshim-0.24.0 → agentshim-0.24.1}/agentshim/providers/codex/__init__.py +0 -0
  79. {agentshim-0.24.0 → agentshim-0.24.1}/agentshim/providers/codex/_toml.py +0 -0
  80. {agentshim-0.24.0 → agentshim-0.24.1}/agentshim/providers/codex/app_server/__init__.py +0 -0
  81. {agentshim-0.24.0 → agentshim-0.24.1}/agentshim/providers/codex/app_server/_wire.py +0 -0
  82. {agentshim-0.24.0 → agentshim-0.24.1}/agentshim/providers/codex/app_server/approvals.py +0 -0
  83. {agentshim-0.24.0 → agentshim-0.24.1}/agentshim/providers/codex/app_server/channel.py +0 -0
  84. {agentshim-0.24.0 → agentshim-0.24.1}/agentshim/providers/codex/app_server/errors.py +0 -0
  85. {agentshim-0.24.0 → agentshim-0.24.1}/agentshim/providers/codex/app_server/items.py +0 -0
  86. {agentshim-0.24.0 → agentshim-0.24.1}/agentshim/providers/codex/app_server/mcp.py +0 -0
  87. {agentshim-0.24.0 → agentshim-0.24.1}/agentshim/providers/codex/app_server/permissions.py +0 -0
  88. {agentshim-0.24.0 → agentshim-0.24.1}/agentshim/providers/codex/app_server/profile.py +0 -0
  89. {agentshim-0.24.0 → agentshim-0.24.1}/agentshim/providers/codex/app_server/protocol.py +0 -0
  90. {agentshim-0.24.0 → agentshim-0.24.1}/agentshim/providers/codex/app_server/rate_limits.py +0 -0
  91. {agentshim-0.24.0 → agentshim-0.24.1}/agentshim/providers/codex/app_server/usage.py +0 -0
  92. {agentshim-0.24.0 → agentshim-0.24.1}/agentshim/providers/codex/events.py +0 -0
  93. {agentshim-0.24.0 → agentshim-0.24.1}/agentshim/providers/codex/failures.py +0 -0
  94. {agentshim-0.24.0 → agentshim-0.24.1}/agentshim/providers/codex/mcp_scope.py +0 -0
  95. {agentshim-0.24.0 → agentshim-0.24.1}/agentshim/providers/codex/model_data.py +0 -0
  96. {agentshim-0.24.0 → agentshim-0.24.1}/agentshim/providers/codex/parser.py +0 -0
  97. {agentshim-0.24.0 → agentshim-0.24.1}/agentshim/providers/codex/plugin.py +0 -0
  98. {agentshim-0.24.0 → agentshim-0.24.1}/agentshim/providers/codex/prices_generated.py +0 -0
  99. {agentshim-0.24.0 → agentshim-0.24.1}/agentshim/providers/codex/probe.py +0 -0
  100. {agentshim-0.24.0 → agentshim-0.24.1}/agentshim/providers/codex/provider.py +0 -0
  101. {agentshim-0.24.0 → agentshim-0.24.1}/agentshim/providers/codex/rules.py +0 -0
  102. {agentshim-0.24.0 → agentshim-0.24.1}/agentshim/providers/codex/sandbox.py +0 -0
  103. {agentshim-0.24.0 → agentshim-0.24.1}/agentshim/providers/codex/scripted.py +0 -0
  104. {agentshim-0.24.0 → agentshim-0.24.1}/agentshim/providers/codex/skills.py +0 -0
  105. {agentshim-0.24.0 → agentshim-0.24.1}/agentshim/providers/codex/speed.py +0 -0
  106. {agentshim-0.24.0 → agentshim-0.24.1}/agentshim/providers/codex/testing.py +0 -0
  107. {agentshim-0.24.0 → agentshim-0.24.1}/agentshim/providers/copilot/__init__.py +0 -0
  108. {agentshim-0.24.0 → agentshim-0.24.1}/agentshim/providers/copilot/events.py +0 -0
  109. {agentshim-0.24.0 → agentshim-0.24.1}/agentshim/providers/copilot/parser.py +0 -0
  110. {agentshim-0.24.0 → agentshim-0.24.1}/agentshim/providers/copilot/plugin.py +0 -0
  111. {agentshim-0.24.0 → agentshim-0.24.1}/agentshim/providers/copilot/probe.py +0 -0
  112. {agentshim-0.24.0 → agentshim-0.24.1}/agentshim/providers/copilot/provider.py +0 -0
  113. {agentshim-0.24.0 → agentshim-0.24.1}/agentshim/providers/copilot/scripted.py +0 -0
  114. {agentshim-0.24.0 → agentshim-0.24.1}/agentshim/providers/copilot/testing.py +0 -0
  115. {agentshim-0.24.0 → agentshim-0.24.1}/agentshim/providers/gemini/__init__.py +0 -0
  116. {agentshim-0.24.0 → agentshim-0.24.1}/agentshim/providers/gemini/events.py +0 -0
  117. {agentshim-0.24.0 → agentshim-0.24.1}/agentshim/providers/gemini/parser.py +0 -0
  118. {agentshim-0.24.0 → agentshim-0.24.1}/agentshim/providers/gemini/plugin.py +0 -0
  119. {agentshim-0.24.0 → agentshim-0.24.1}/agentshim/providers/gemini/probe.py +0 -0
  120. {agentshim-0.24.0 → agentshim-0.24.1}/agentshim/providers/gemini/provider.py +0 -0
  121. {agentshim-0.24.0 → agentshim-0.24.1}/agentshim/providers/gemini/scripted.py +0 -0
  122. {agentshim-0.24.0 → agentshim-0.24.1}/agentshim/providers/gemini/testing.py +0 -0
  123. {agentshim-0.24.0 → agentshim-0.24.1}/agentshim/providers/opencode/__init__.py +0 -0
  124. {agentshim-0.24.0 → agentshim-0.24.1}/agentshim/providers/opencode/events.py +0 -0
  125. {agentshim-0.24.0 → agentshim-0.24.1}/agentshim/providers/opencode/model_data.py +0 -0
  126. {agentshim-0.24.0 → agentshim-0.24.1}/agentshim/providers/opencode/parser.py +0 -0
  127. {agentshim-0.24.0 → agentshim-0.24.1}/agentshim/providers/opencode/plugin.py +0 -0
  128. {agentshim-0.24.0 → agentshim-0.24.1}/agentshim/providers/opencode/probe.py +0 -0
  129. {agentshim-0.24.0 → agentshim-0.24.1}/agentshim/providers/opencode/provider.py +0 -0
  130. {agentshim-0.24.0 → agentshim-0.24.1}/agentshim/providers/opencode/scripted.py +0 -0
  131. {agentshim-0.24.0 → agentshim-0.24.1}/agentshim/providers/opencode/testing.py +0 -0
  132. {agentshim-0.24.0 → agentshim-0.24.1}/agentshim/providers/static_plugin.py +0 -0
  133. {agentshim-0.24.0 → agentshim-0.24.1}/agentshim/py.typed +0 -0
  134. {agentshim-0.24.0 → agentshim-0.24.1}/agentshim/runtime.py +0 -0
  135. {agentshim-0.24.0 → agentshim-0.24.1}/agentshim/session.py +0 -0
  136. {agentshim-0.24.0 → agentshim-0.24.1}/agentshim/testing/__init__.py +0 -0
  137. {agentshim-0.24.0 → agentshim-0.24.1}/agentshim/testing/claude_stream.py +0 -0
  138. {agentshim-0.24.0 → agentshim-0.24.1}/agentshim/testing/clock.py +0 -0
  139. {agentshim-0.24.0 → agentshim-0.24.1}/agentshim/testing/codex_app_server.py +0 -0
  140. {agentshim-0.24.0 → agentshim-0.24.1}/agentshim/testing/confinement.py +0 -0
  141. {agentshim-0.24.0 → agentshim-0.24.1}/agentshim/testing/contracts.py +0 -0
  142. {agentshim-0.24.0 → agentshim-0.24.1}/agentshim/testing/conversation.py +0 -0
  143. {agentshim-0.24.0 → agentshim-0.24.1}/agentshim/testing/probe.py +0 -0
  144. {agentshim-0.24.0 → agentshim-0.24.1}/agentshim/testing/process.py +0 -0
  145. {agentshim-0.24.0 → agentshim-0.24.1}/agentshim/testing/rates.py +0 -0
  146. {agentshim-0.24.0 → agentshim-0.24.1}/tests/fixtures/claude/README.md +0 -0
  147. {agentshim-0.24.0 → agentshim-0.24.1}/tests/fixtures/claude_stream/README.md +0 -0
  148. {agentshim-0.24.0 → agentshim-0.24.1}/tests/fixtures/codex/README.md +0 -0
  149. {agentshim-0.24.0 → agentshim-0.24.1}/tests/fixtures/codex_app_server/README.md +0 -0
@@ -1,5 +1,78 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.24.1 (2026-10-31)
4
+
5
+ Fixes the usage numbers 0.24.0 got wrong. Recorded cost does not move for any single-result turn:
6
+ every price, `price_for` and `cost_usd` are as in 0.24.0, and `tests/fixtures/recorded_costs.json`
7
+ still proves it.
8
+
9
+ ### Fixed
10
+
11
+ - **Claude transcripts undercounted output by about a third.** The CLI writes one line per content
12
+ block of a message, and the lines do not repeat the same usage: the prompt side is identical, but
13
+ `output_tokens` grows as the reply streams, from a few tokens on the first line to the whole reply
14
+ on the last. 0.24.0 kept the first line and dropped the rest. A message is now counted once, at
15
+ the most any line reported, and the records of a message add up to its final usage: the first
16
+ line yields the request (`turns=1`), each later line yields only the tokens it added (`turns=0`,
17
+ usually only output). Sum tokens over records, and count requests by summing `turns`. An advisor
18
+ call that shows only on a later line is counted too. A position saved by 0.24.0 has no such
19
+ counts, so it is refused and the file is read again from the start (`reset`), which also corrects
20
+ what 0.24.0 missed.
21
+ - **`ProviderUsage.by_model` counted the first turn after a steer twice.** `modelUsage` is
22
+ cumulative over the CLI process, and the parser added each result's rows to the last. The rows of
23
+ a run are now those of its last result, which cover the run once, and the rows sum to
24
+ `total_cost_usd`. The same seam is fixed for the primary row's `turns` (the run's, not the last
25
+ result's) and for the one-hour share of cache writes (the run's, applied to the run's writes), so a
26
+ steered run whose results differ in cache TTL is no longer priced at the last result's share.
27
+ A single result is unchanged.
28
+ - **A moved Codex session file was counted twice.** Codex archives a rollout by moving it from
29
+ `sessions` to `archived_sessions` under the same name. agentshim now owns that: a file with the
30
+ name of one already read is read on from the furthest position held for it (after the usual check
31
+ of the bytes before that position), and a copy that is shorter than what was read holds nothing
32
+ new. Keep one `known` mapping for all roots of a provider, as before. A caller no longer
33
+ de-duplicates anything. **Removed:** `TranscriptRoot.shared_sessions` and
34
+ `TranscriptFormat.shared_sessions`, which only told the caller to do this itself; a format sets
35
+ `session_file` (the name every copy of a session's file has) instead.
36
+ - **A fetched LiteLLM document took known prices away.** It replaced the pinned table whole, so a
37
+ model LiteLLM dropped or renamed lost its estimate, and a row listed at zero before its price was
38
+ known turned a known price into $0. A document is now laid over the pinned table: a model it lacks
39
+ keeps its pinned rates, a row with no input or output price does not replace a pinned price, and a
40
+ speed tier with no price under a priced standard is dropped. A model only the document knows is
41
+ taken as listed, free or not. `RateRead.known_models` counts the result.
42
+ - A `modelUsage` key with no name no longer raises out of the parser; its tokens are the row
43
+ `unknown`, which has no price, so the turn is `UNKNOWN_MODEL` as in 0.23.1. The CLI's
44
+ `<synthetic>` placeholder bills nothing and no longer makes a turn `UNKNOWN_MODEL`.
45
+ - `ultrafast` is read as `UsageSpeed.ULTRAFAST` in a Claude result and in a transcript (it read as
46
+ none, and as standard). A speed agentshim has no name for is still `None` (read as standard).
47
+ - `read_rates` and `refresh_rates` reject a naive `now` with `ValueError` before anything is
48
+ fetched. A clock set back is tolerated: a time stamped ahead of `now` by more than the floor has an
49
+ unknown age and counts as expired (it was fresh until the clock caught up), and an attempt more than
50
+ the floor away no longer holds off a refresh.
51
+ - The Codex app-server transport skips the `by_model` row of a model name that is blank.
52
+
53
+ ### Changed
54
+
55
+ - `read_transcript_usage(..., include_cwd=False)`: records carry `cwd` only when asked. It is an
56
+ absolute path on the user's machine, so a record a caller stores or sends holds no personal path by
57
+ default.
58
+ - `ModelUsage.recorded_usd` is documented as what it was: the pinned table's standard-tier list
59
+ price, no bill. A row whose `speed` is fast or ultrafast was billed more; docs/pricing.md shows
60
+ how to price the tier with `rates_for`, `price_tokens` and `speed_premium_usd`. Recorded cost still
61
+ does not model speed tiers.
62
+ - docs/transcripts.md states that `since` selects files, not records (filter on `timestamp`), and
63
+ that a line is held in memory whole while it is read.
64
+
65
+ ### Added
66
+
67
+ - Tests built from data shaped like what the CLIs write, not idealised input:
68
+ `tests/unit/transcripts/shapes.py` (streamed Claude messages whose output grows, advisor calls
69
+ that show late, Codex session files that grow, move and are copied),
70
+ `tests/unit/stream_shapes.py` (a steered Claude process with cumulative `modelUsage`),
71
+ `tests/unit/rate_shapes.py` (partial, renamed and placeholder LiteLLM documents), and a synthetic
72
+ session file shaped like a real streamed message (`tests/fixtures/claude_session`). Properties over
73
+ them: records summed over any read split equal the final usage; a moved or copied session is
74
+ counted once; steered results equal one result of the whole run; no known price becomes 0.
75
+
3
76
  ## 0.24.0 (2026-10-30)
4
77
 
5
78
  Everything here is additive. Recorded cost does not move: every price, `price_for`, `cost_usd`,
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: agentshim
3
- Version: 0.24.0
3
+ Version: 0.24.1
4
4
  Summary: Provider-agnostic coding-agent CLI shims
5
5
  License-Expression: MIT
6
6
  License-File: LICENSE
@@ -226,7 +226,7 @@ from .runtime import Agent
226
226
  from .session import Session, Turn, TurnTicket, map_mcp_servers
227
227
  from .transcripts import read_transcript_usage, transcript_providers, transcript_roots
228
228
 
229
- __version__ = "0.24.0"
229
+ __version__ = "0.24.1"
230
230
 
231
231
  __all__ = [ # noqa: RUF022 - public API test uses Python's case-sensitive order
232
232
  "Agent",
@@ -8,6 +8,8 @@ is each provider's :class:`TranscriptFormat`.
8
8
 
9
9
  agentshim keeps no state between calls. The caller remembers a
10
10
  :class:`FilePosition` per file and hands it back, and gets only what is new.
11
+ The positions of every root of a provider go in one mapping: a session file
12
+ that moves between roots is then read on from where the old path stopped.
11
13
  """
12
14
 
13
15
  from __future__ import annotations
@@ -40,28 +42,30 @@ class TranscriptRoot:
40
42
  Attributes:
41
43
  provider: The provider that wrote them.
42
44
  path: The directory, resolved from the environment (it may not exist).
43
- shared_sessions: Whether one session's records can appear in more than
44
- one file under this provider, because a session file moved after
45
- it was read. A caller then counts each session from one file only.
46
45
  """
47
46
 
48
47
  provider: str
49
48
  path: Path
50
- shared_sessions: bool = False
51
49
 
52
50
 
53
51
  @dataclass(frozen=True)
54
52
  class TranscriptRecord:
55
- """One model request's usage, as a transcript recorded it.
53
+ """One model request's usage, or the part of it one line of a transcript added.
56
54
 
57
55
  Attributes:
58
- timestamp: When the request was recorded (timezone-aware).
56
+ timestamp: When the request was recorded (timezone-aware). For the
57
+ tokens a later line of the file added, when that line was written.
59
58
  model: The model id as the transcript names it.
60
59
  session_id: The session it belongs to; a subagent's records carry
61
60
  their parent's session.
62
- cwd: The working directory the session ran in, when the file says.
61
+ cwd: The working directory the session ran in: an absolute path on the
62
+ user's machine, so a personal one. ``None`` unless the caller asked
63
+ for it with ``include_cwd``, and then only when the file says.
63
64
  tokens: The request's tokens. ``input_tokens`` includes cache reads and
64
- writes, as everywhere in agentshim.
65
+ writes, as everywhere in agentshim. A request is counted once, at
66
+ the most any line of the file reported: the records of one request
67
+ add up to its final usage, the first holding the request and its
68
+ ``turns``, and later ones (``turns`` 0) only what a later line added.
65
69
  speed: The service speed it was billed at.
66
70
  reported_usd: A cost the file states. ``None`` for every shipped
67
71
  format: a CLI's own figure is not trusted (see :mod:`agentshim.providers.claude.cost`).
@@ -153,7 +157,9 @@ class TranscriptScan:
153
157
  Attributes:
154
158
  files: The files read this call, in path order.
155
159
  unchanged: Files whose size and modification time match the known
156
- position of a finished read, so nothing was read.
160
+ position of a finished read, and files that are earlier copies of
161
+ a session already read further under another path, so nothing was
162
+ read.
157
163
  skipped: Files too old to matter for the window.
158
164
  pending: Files not finished: the byte budget ran out first. Call again
159
165
  with the new positions until it is zero.
@@ -211,7 +217,11 @@ class TranscriptFormat:
211
217
  suffix: Which file names are transcripts.
212
218
  unreadable_suffixes: Transcript names agentshim cannot read (a
213
219
  compressed archive). They are reported in ``failed``, not skipped.
214
- shared_sessions: See :attr:`TranscriptRoot.shared_sessions`.
220
+ session_file: The name every copy of one session's file has, for a
221
+ provider that moves or copies a file between its directories after
222
+ it was read. A file with the name of one already read is read on
223
+ from there, so the move adds nothing twice. ``None`` when a file
224
+ keeps its path.
215
225
  """
216
226
 
217
227
  subdirs: tuple[str, ...]
@@ -220,7 +230,7 @@ class TranscriptFormat:
220
230
  parser: Callable[[Path, bytes | None], TranscriptParser | None]
221
231
  suffix: str = ".jsonl"
222
232
  unreadable_suffixes: tuple[str, ...] = field(default=())
223
- shared_sessions: bool = False
233
+ session_file: Callable[[Path], str] | None = None
224
234
 
225
235
  def __post_init__(self) -> None:
226
236
  """Reject a format that would read nothing, or read non-ASCII markers."""
@@ -293,9 +293,14 @@ class ModelUsage:
293
293
  model: The model id as the provider reports it.
294
294
  tokens: This turn's tokens for this model. Only the primary model
295
295
  carries the turn's ``turns`` count.
296
- recorded_usd: The pinned-table price of ``tokens``; ``None`` when the
297
- table has no price for the model. This is the figure a turn's
298
- recorded cost sums, never the CLI's own.
296
+ recorded_usd: The pinned-table price of ``tokens`` at the standard
297
+ tier; ``None`` when the table has no price for the model. This is
298
+ the figure a turn's recorded cost sums, never the CLI's own. It is
299
+ not a bill: the pinned table has no speed tiers, so a row whose
300
+ ``speed`` is fast or ultrafast was billed more than this. For the
301
+ price at the tier, use :func:`agentshim.price_tokens` with the
302
+ entry's rates at ``speed``, or add
303
+ :func:`agentshim.speed_premium_usd`.
299
304
  speed: The service speed the turn ran at, on the primary model only;
300
305
  ``None`` when the provider did not report one (read as standard).
301
306
  primary: Whether this is the model the turn was run with. Exactly one
@@ -332,7 +337,10 @@ class ModelUsage:
332
337
  def merge_model_usage(
333
338
  first: Sequence[ModelUsage], second: Sequence[ModelUsage]
334
339
  ) -> tuple[ModelUsage, ...]:
335
- """Sum the per-model usage of two turns of one run into one.
340
+ """Sum the per-model usage of two separate turns into one.
341
+
342
+ This adds turns that each have their own rows. Rows a parser built from a
343
+ CLI's cumulative counts already cover the whole run and must not be added.
336
344
 
337
345
  Rows with the same model and speed add up, and a sum is unpriced if either
338
346
  part was. The primary row of *first* (else of *second*) stays the only
@@ -22,12 +22,16 @@ from agentshim.core.rates import candidate_names
22
22
  from agentshim.core.usage import CostSource, ModelUsage, TokenUsage, UsageSpeed, normalized_usage
23
23
  from agentshim.pricing import price_for
24
24
 
25
+ from .model_data import SYNTHETIC_MODEL
26
+
25
27
  if TYPE_CHECKING:
26
28
  from collections.abc import Iterable, Mapping, Sequence
27
29
 
28
30
  from agentshim.core.pricing import PricingTable
29
31
 
30
32
  PROVIDER_NAME = "claude"
33
+ #: What a ``modelUsage`` key with no name is called: its tokens are real, but no price can match.
34
+ UNNAMED_MODEL = "unknown"
31
35
 
32
36
 
33
37
  @dataclass(frozen=True)
@@ -60,6 +64,17 @@ class ModelCounts:
60
64
  canonical=self.canonical,
61
65
  )
62
66
 
67
+ def plus(self, other: ModelCounts) -> ModelCounts:
68
+ """These counts and *other*'s added together, naming the model as this one does."""
69
+ return ModelCounts(
70
+ input_tokens=self.input_tokens + other.input_tokens,
71
+ output_tokens=self.output_tokens + other.output_tokens,
72
+ cache_read_tokens=self.cache_read_tokens + other.cache_read_tokens,
73
+ cache_write_tokens=self.cache_write_tokens + other.cache_write_tokens,
74
+ reasoning_tokens=self.reasoning_tokens + other.reasoning_tokens,
75
+ canonical=self.canonical or other.canonical,
76
+ )
77
+
63
78
  @property
64
79
  def empty(self) -> bool:
65
80
  """Whether no token was counted."""
@@ -87,7 +102,12 @@ class TurnCost:
87
102
 
88
103
 
89
104
  def parse_model_usage(raw: object) -> dict[str, ModelCounts] | None:
90
- """Read a ``result`` frame's ``modelUsage``; ``None`` when it is absent or malformed."""
105
+ """Read a ``result`` frame's ``modelUsage``; ``None`` when it is absent or malformed.
106
+
107
+ A key with no name (empty or blank) is a CLI fault, not a reason to fail the turn: its
108
+ tokens are kept under :data:`UNNAMED_MODEL`, which has no price, so the turn is
109
+ ``UNKNOWN_MODEL`` with no cost as it is for any other model agentshim cannot price.
110
+ """
91
111
  if not isinstance(raw, dict):
92
112
  return None
93
113
  parsed: dict[str, ModelCounts] = {}
@@ -96,7 +116,7 @@ def parse_model_usage(raw: object) -> dict[str, ModelCounts] | None:
96
116
  return None
97
117
  fields = cast("Mapping[str, Any]", counts)
98
118
  canonical = fields.get("canonicalModel")
99
- parsed[model] = ModelCounts(
119
+ read = ModelCounts(
100
120
  input_tokens=_count(fields, "inputTokens"),
101
121
  output_tokens=_count(fields, "outputTokens"),
102
122
  cache_read_tokens=_count(fields, "cacheReadInputTokens"),
@@ -104,6 +124,8 @@ def parse_model_usage(raw: object) -> dict[str, ModelCounts] | None:
104
124
  reasoning_tokens=_count(fields, "thinkingTokens"),
105
125
  canonical=canonical if isinstance(canonical, str) else None,
106
126
  )
127
+ name = model if model.strip() else UNNAMED_MODEL
128
+ parsed[name] = parsed[name].plus(read) if name in parsed else read
107
129
  return parsed
108
130
 
109
131
 
@@ -118,13 +140,15 @@ def priced_turn(
118
140
 
119
141
  *one_hour_share* is the fraction of cache writes billed at the one-hour
120
142
  rate. A model with tokens in the turn and no price makes the whole turn
121
- ``UNKNOWN_MODEL`` with no cost: a partial sum would read as complete.
143
+ ``UNKNOWN_MODEL`` with no cost: a partial sum would read as complete. The
144
+ CLI's own placeholder for a message it wrote itself bills nothing and has
145
+ no row.
122
146
  """
123
147
  total: float | None = 0.0
124
148
  rows: list[ModelUsage] = []
125
149
  for model, counts in model_usage.items():
126
150
  delta = counts.since(baseline.get(model))
127
- if delta.empty:
151
+ if delta.empty or model == SYNTHETIC_MODEL:
128
152
  continue
129
153
  pricing = price_for(PROVIDER_NAME, model, table)
130
154
  if pricing is None and delta.canonical is not None:
@@ -181,9 +205,14 @@ def mark_primary(
181
205
 
182
206
 
183
207
  def report_speed(usage: Mapping[str, Any] | None) -> UsageSpeed | None:
184
- """The speed a ``result`` frame's ``usage`` says the turn ran at; ``None`` if it says none."""
208
+ """The speed a ``usage`` mapping says the request ran at.
209
+
210
+ ``None`` when it says none, or names a speed agentshim has no
211
+ :class:`UsageSpeed` for: the caller then reads it as standard, and the
212
+ figure is a list price of the standard tier whatever was billed.
213
+ """
185
214
  speed = None if usage is None else usage.get("speed")
186
- return {"standard": UsageSpeed.STANDARD, "fast": UsageSpeed.FAST}.get(str(speed))
215
+ return next((tier for tier in UsageSpeed if tier.value == speed), None)
187
216
 
188
217
 
189
218
  def _tokens(
@@ -93,6 +93,8 @@ PRICING_ENTRIES = tuple(_anthropic(entry) for entry in RATE_ENTRIES)
93
93
  #: Flat rates with speed tiers, for estimates; the same generated numbers as ``PRICING_ENTRIES``.
94
94
  RATES = RateTable(SNAPSHOT_DATE, {entry.name: entry for entry in RATE_ENTRIES})
95
95
 
96
+ #: The ``model`` of a message the CLI wrote itself rather than a model call, which bills nothing.
97
+ SYNTHETIC_MODEL = "<synthetic>"
96
98
  #: Names the CLI reports that are not a model with a price: its placeholder for a message that
97
99
  #: never reached the API, and the bare family names, which do not say which generation ran.
98
- UNPRICEABLE_MODELS = frozenset({"<synthetic>", "synthetic", "opus", "sonnet", "haiku", "fable"})
100
+ UNPRICEABLE_MODELS = frozenset({SYNTHETIC_MODEL, "synthetic", "opus", "sonnet", "haiku", "fable"})
@@ -26,7 +26,6 @@ from agentshim.core.usage import (
26
26
  ModelUsage,
27
27
  ProviderUsage,
28
28
  TokenUsage,
29
- merge_model_usage,
30
29
  normalized_usage,
31
30
  )
32
31
 
@@ -41,6 +40,7 @@ from .events import (
41
40
  parse_frame,
42
41
  )
43
42
  from .failures import SCHEMA_RETRIES_SUBTYPE, classify_failure
43
+ from .model_data import SYNTHETIC_MODEL
44
44
  from .rate_limits import RateLimitFrame
45
45
 
46
46
  if TYPE_CHECKING:
@@ -96,10 +96,6 @@ def fold_usage(usage: Mapping[str, Any] | None, turns: int = 0) -> TokenUsage:
96
96
  )
97
97
 
98
98
 
99
- #: The ``model`` of a message Claude Code wrote itself rather than a model.
100
- SYNTHETIC_MODEL = "<synthetic>"
101
-
102
-
103
99
  def context_tokens(usage: Mapping[str, Any]) -> int:
104
100
  """Tokens one model call leaves in the context: its whole prompt plus its output.
105
101
 
@@ -343,14 +339,14 @@ class ClaudeStreamParser:
343
339
  self._final_text = frame.text
344
340
  self.total_cost_usd = frame.total_cost_usd
345
341
  tokens = fold_usage(frame.usage, turns=frame.num_turns or 0)
346
- self._price(frame, tokens)
347
- self._structured = self._structured_payload(frame)
348
- by_model = self._by_model
349
342
  if self._results:
350
- # A steered message can start a follow-on turn inside this run; its
351
- # ``result`` reports only its own tokens, so the run sums them.
343
+ # A steered message can start a follow-on turn inside this run. Its ``usage`` reports
344
+ # only its own tokens, so the run sums them; ``modelUsage`` and ``total_cost_usd``
345
+ # are cumulative over the process, so the cost and the per-model rows priced from
346
+ # them already cover every result of the run and are taken as they are, not added.
352
347
  tokens = self._usage.tokens + tokens
353
- by_model = merge_model_usage(self._usage.by_model, by_model)
348
+ self._price(frame, tokens)
349
+ self._structured = self._structured_payload(frame)
354
350
  self._results += 1
355
351
  self._usage = ProviderUsage(
356
352
  tokens=tokens,
@@ -358,7 +354,7 @@ class ClaudeStreamParser:
358
354
  provider=PROVIDER_NAME,
359
355
  raw=frame.usage,
360
356
  cost_source=self._cost_source,
361
- by_model=by_model,
357
+ by_model=self._by_model,
362
358
  )
363
359
  self._emit(UsageReport(self._usage, self._cost_usd))
364
360
  if frame.is_error:
@@ -369,7 +365,12 @@ class ClaudeStreamParser:
369
365
  self._emit(ProviderError(self._error))
370
366
 
371
367
  def _price(self, frame: ResultFrame, tokens: TokenUsage) -> None:
372
- """Set the turn's cost from its per-model tokens, or from the CLI's when it names none."""
368
+ """Set the run's cost from its per-model tokens, or from the CLI's when it names none.
369
+
370
+ *tokens* are the run's so far, the same span the cumulative ``modelUsage`` covers: their
371
+ one-hour share of cache writes applies to those writes, and their turn count goes on the
372
+ primary row.
373
+ """
373
374
  if frame.model_usage is not None:
374
375
  self.model_usage_total = frame.model_usage
375
376
  writes = tokens.cache_write_input_tokens