agentshim 0.6.8__tar.gz → 0.7.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 (60) hide show
  1. {agentshim-0.6.8 → agentshim-0.7.0}/CHANGELOG.md +52 -0
  2. {agentshim-0.6.8 → agentshim-0.7.0}/PKG-INFO +4 -3
  3. {agentshim-0.6.8 → agentshim-0.7.0}/README.md +3 -2
  4. {agentshim-0.6.8 → agentshim-0.7.0}/agentshim/__init__.py +15 -1
  5. {agentshim-0.6.8 → agentshim-0.7.0}/agentshim/core/__init__.py +9 -1
  6. agentshim-0.7.0/agentshim/core/pricing.py +466 -0
  7. agentshim-0.7.0/agentshim/core/usage.py +287 -0
  8. {agentshim-0.6.8 → agentshim-0.7.0}/agentshim/providers/claude/parser.py +18 -10
  9. {agentshim-0.6.8 → agentshim-0.7.0}/agentshim/providers/claude/scripted.py +8 -4
  10. {agentshim-0.6.8 → agentshim-0.7.0}/agentshim/providers/codex/events.py +7 -2
  11. {agentshim-0.6.8 → agentshim-0.7.0}/agentshim/providers/codex/parser.py +9 -6
  12. {agentshim-0.6.8 → agentshim-0.7.0}/agentshim/providers/codex/scripted.py +13 -4
  13. {agentshim-0.6.8 → agentshim-0.7.0}/agentshim/providers/copilot/parser.py +7 -8
  14. {agentshim-0.6.8 → agentshim-0.7.0}/agentshim/providers/copilot/scripted.py +2 -3
  15. {agentshim-0.6.8 → agentshim-0.7.0}/agentshim/providers/gemini/parser.py +9 -8
  16. {agentshim-0.6.8 → agentshim-0.7.0}/agentshim/providers/opencode/parser.py +9 -8
  17. {agentshim-0.6.8 → agentshim-0.7.0}/agentshim/providers/opencode/scripted.py +3 -3
  18. {agentshim-0.6.8 → agentshim-0.7.0}/pyproject.toml +1 -1
  19. agentshim-0.6.8/agentshim/core/usage.py +0 -78
  20. {agentshim-0.6.8 → agentshim-0.7.0}/.gitignore +0 -0
  21. {agentshim-0.6.8 → agentshim-0.7.0}/agentshim/agent.py +0 -0
  22. {agentshim-0.6.8 → agentshim-0.7.0}/agentshim/core/_files.py +0 -0
  23. {agentshim-0.6.8 → agentshim-0.7.0}/agentshim/core/env.py +0 -0
  24. {agentshim-0.6.8 → agentshim-0.7.0}/agentshim/core/errors.py +0 -0
  25. {agentshim-0.6.8 → agentshim-0.7.0}/agentshim/core/events.py +0 -0
  26. {agentshim-0.6.8 → agentshim-0.7.0}/agentshim/core/mcp.py +0 -0
  27. {agentshim-0.6.8 → agentshim-0.7.0}/agentshim/core/profile.py +0 -0
  28. {agentshim-0.6.8 → agentshim-0.7.0}/agentshim/core/provider.py +0 -0
  29. {agentshim-0.6.8 → agentshim-0.7.0}/agentshim/core/schema.py +0 -0
  30. {agentshim-0.6.8 → agentshim-0.7.0}/agentshim/core/stream.py +0 -0
  31. {agentshim-0.6.8 → agentshim-0.7.0}/agentshim/core/turn.py +0 -0
  32. {agentshim-0.6.8 → agentshim-0.7.0}/agentshim/execution/__init__.py +0 -0
  33. {agentshim-0.6.8 → agentshim-0.7.0}/agentshim/execution/executor.py +0 -0
  34. {agentshim-0.6.8 → agentshim-0.7.0}/agentshim/execution/host.py +0 -0
  35. {agentshim-0.6.8 → agentshim-0.7.0}/agentshim/execution/transform.py +0 -0
  36. {agentshim-0.6.8 → agentshim-0.7.0}/agentshim/providers/__init__.py +0 -0
  37. {agentshim-0.6.8 → agentshim-0.7.0}/agentshim/providers/claude/__init__.py +0 -0
  38. {agentshim-0.6.8 → agentshim-0.7.0}/agentshim/providers/claude/events.py +0 -0
  39. {agentshim-0.6.8 → agentshim-0.7.0}/agentshim/providers/claude/hooks/__init__.py +0 -0
  40. {agentshim-0.6.8 → agentshim-0.7.0}/agentshim/providers/claude/hooks/confine_reads.py +0 -0
  41. {agentshim-0.6.8 → agentshim-0.7.0}/agentshim/providers/claude/provider.py +0 -0
  42. {agentshim-0.6.8 → agentshim-0.7.0}/agentshim/providers/claude/sandbox.py +0 -0
  43. {agentshim-0.6.8 → agentshim-0.7.0}/agentshim/providers/claude/user_hooks.py +0 -0
  44. {agentshim-0.6.8 → agentshim-0.7.0}/agentshim/providers/codex/__init__.py +0 -0
  45. {agentshim-0.6.8 → agentshim-0.7.0}/agentshim/providers/codex/_toml.py +0 -0
  46. {agentshim-0.6.8 → agentshim-0.7.0}/agentshim/providers/codex/provider.py +0 -0
  47. {agentshim-0.6.8 → agentshim-0.7.0}/agentshim/providers/codex/rules.py +0 -0
  48. {agentshim-0.6.8 → agentshim-0.7.0}/agentshim/providers/codex/sandbox.py +0 -0
  49. {agentshim-0.6.8 → agentshim-0.7.0}/agentshim/providers/copilot/__init__.py +0 -0
  50. {agentshim-0.6.8 → agentshim-0.7.0}/agentshim/providers/copilot/events.py +0 -0
  51. {agentshim-0.6.8 → agentshim-0.7.0}/agentshim/providers/copilot/provider.py +0 -0
  52. {agentshim-0.6.8 → agentshim-0.7.0}/agentshim/providers/gemini/__init__.py +0 -0
  53. {agentshim-0.6.8 → agentshim-0.7.0}/agentshim/providers/gemini/events.py +0 -0
  54. {agentshim-0.6.8 → agentshim-0.7.0}/agentshim/providers/gemini/provider.py +0 -0
  55. {agentshim-0.6.8 → agentshim-0.7.0}/agentshim/providers/gemini/scripted.py +0 -0
  56. {agentshim-0.6.8 → agentshim-0.7.0}/agentshim/providers/opencode/__init__.py +0 -0
  57. {agentshim-0.6.8 → agentshim-0.7.0}/agentshim/providers/opencode/events.py +0 -0
  58. {agentshim-0.6.8 → agentshim-0.7.0}/agentshim/providers/opencode/provider.py +0 -0
  59. {agentshim-0.6.8 → agentshim-0.7.0}/agentshim/py.typed +0 -0
  60. {agentshim-0.6.8 → agentshim-0.7.0}/agentshim/testing/__init__.py +0 -0
@@ -1,5 +1,57 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.7.0 (2026-09-27)
4
+
5
+ First-class cache accounting and a static pricing table. Additive except one
6
+ change of meaning: `cached_input_tokens` now counts cache reads only (see
7
+ Changed).
8
+
9
+ ### Added
10
+
11
+ - `TokenUsage.cache_read_input_tokens` (input served from the prompt cache),
12
+ `cache_write_1h_input_tokens` (the one-hour-TTL part of cache writes, from
13
+ Claude's `cache_creation` breakdown) and the derived
14
+ `uncached_input_tokens` (`input - cache_read - cache_write`). `to_dict()`
15
+ gains the three keys; no key was removed.
16
+ - Codex turns now report `cache_write_input_tokens` and
17
+ `reasoning_output_tokens` from `turn.completed` (both used to be dropped).
18
+ - `TokenUsage` checks its invariants on construction:
19
+ `cache_read + cache_write <= input`, `cache_write_1h <= cache_write`,
20
+ `reasoning <= output`, no negative counts. Parsers build it through the new
21
+ `normalized_usage`, which clamps a CLI's inconsistent counts instead.
22
+ - `TokenUsage.from_dict` reads a `to_dict()` mapping back, including one
23
+ written before 0.7.0.
24
+ - `TokenWeights` and `TokenUsage.weighted_total(weights)`: a total weighted by
25
+ token class (uncached input, cache reads, 5m and 1h cache writes, output,
26
+ reasoning), each token weighted once.
27
+ - A static, versioned pricing table (`agentshim.core.pricing`):
28
+ `ModelPricing`, `PricingTable`, `default_pricing()`,
29
+ `price_for(provider, model, table=None)` (`None` for an unknown model, never
30
+ zero), `cost_usd(usage, pricing)` and `ModelPricing.relative_weights()`.
31
+ It covers OpenAI GPT-6 (astra, sol, luna), GPT-5.6, GPT-5.5 and earlier
32
+ GPT-5 models, and Claude Fable 5.1, Fable 5, Opus 5.5, Opus 5, Opus 4.8,
33
+ Sonnet 5, Sonnet 4.6 and Haiku 4.5, each with its official source URL and
34
+ check date (all 2026-09-27). Callers override or extend it with
35
+ `PricingTable.with_entries` or `PricingTable.from_dict`. Holding prices in
36
+ agentshim is a user-directed decision; see `docs/pricing.md`.
37
+ - Recorded Codex, Claude (5m and 1h cache writes), opencode and Gemini
38
+ streams under `tests/fixtures/`, with tests pinning each provider's mapping.
39
+ The Claude Haiku 4.5 recording's `total_cost_usd` is reproduced exactly by
40
+ the table.
41
+
42
+ ### Changed
43
+
44
+ - `cached_input_tokens` is a deprecated alias of `cache_read_input_tokens`.
45
+ On Claude, opencode and Copilot it used to be reads plus writes; it is now
46
+ reads only, since a cache write is billed above base input and a read far
47
+ below it. Codex and Gemini report no writes in their cached count, so their
48
+ value is unchanged. Constructing with `cached_input_tokens=` still works.
49
+
50
+ ### Documented
51
+
52
+ - Claude's `result.usage` covers the main conversation only; a Task
53
+ subagent's tokens appear only in `modelUsage` and `total_cost_usd`.
54
+
3
55
  ## 0.6.8 (2026-09-27)
4
56
 
5
57
  A tightening of the Codex (`STRICT`) schema check: schemas it now rejects
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: agentshim
3
- Version: 0.6.8
3
+ Version: 0.7.0
4
4
  Summary: Provider-agnostic coding-agent CLI shims
5
5
  Requires-Python: >=3.10
6
6
  Provides-Extra: test
@@ -23,8 +23,9 @@ No required runtime dependencies. Python 3.10+.
23
23
 
24
24
  - `CliAgent` / `AgentSession`: one turn at a time, resumable, cancellable
25
25
  - typed events delivered on the calling thread
26
- - normalized token accounting where `cached_input_tokens <= input_tokens` on
27
- every provider
26
+ - normalized token accounting (total input, cache reads, cache writes,
27
+ uncached input, output and reasoning) on every provider, plus a static,
28
+ versioned pricing table (see [Usage and Pricing](docs/pricing.md))
28
29
  - declared capabilities on `ProviderProfile`, so no caller probes a provider
29
30
  - injectable `CommandExecutor`s for running the CLI in a container or over a
30
31
  remote shell
@@ -14,8 +14,9 @@ No required runtime dependencies. Python 3.10+.
14
14
 
15
15
  - `CliAgent` / `AgentSession`: one turn at a time, resumable, cancellable
16
16
  - typed events delivered on the calling thread
17
- - normalized token accounting where `cached_input_tokens <= input_tokens` on
18
- every provider
17
+ - normalized token accounting (total input, cache reads, cache writes,
18
+ uncached input, output and reasoning) on every provider, plus a static,
19
+ versioned pricing table (see [Usage and Pricing](docs/pricing.md))
19
20
  - declared capabilities on `ProviderProfile`, so no caller probes a provider
20
21
  - injectable `CommandExecutor`s for running the CLI in a container or over a
21
22
  remote shell
@@ -29,11 +29,13 @@ from .core import (
29
29
  McpMechanism,
30
30
  McpServer,
31
31
  McpTransport,
32
+ ModelPricing,
32
33
  NoopInstallation,
33
34
  NullEventHandler,
34
35
  OutputSchema,
35
36
  OutputSchemaStyle,
36
37
  ParsedTurn,
38
+ PricingTable,
37
39
  Provider,
38
40
  ProviderCapabilityError,
39
41
  ProviderError,
@@ -51,6 +53,7 @@ from .core import (
51
53
  StdioMcpServer,
52
54
  StreamParser,
53
55
  TokenUsage,
56
+ TokenWeights,
54
57
  ToolCall,
55
58
  ToolResult,
56
59
  ToolTracker,
@@ -59,12 +62,16 @@ from .core import (
59
62
  UsageReport,
60
63
  compact_json,
61
64
  compose_event_handlers,
65
+ cost_usd,
66
+ default_pricing,
62
67
  dialect_problems,
63
68
  install_config_file,
64
69
  interactive_env,
65
70
  materialize,
66
71
  normalize,
72
+ normalized_usage,
67
73
  parse_json_object,
74
+ price_for,
68
75
  )
69
76
  from .execution import (
70
77
  CallbackCommandStreamSink,
@@ -84,7 +91,7 @@ from .providers.copilot import CopilotProvider
84
91
  from .providers.gemini import GeminiProvider
85
92
  from .providers.opencode import OpencodeProvider
86
93
 
87
- __version__ = "0.6.8"
94
+ __version__ = "0.7.0"
88
95
 
89
96
  __all__ = [
90
97
  "AgentEvent",
@@ -123,6 +130,7 @@ __all__ = [
123
130
  "McpMechanism",
124
131
  "McpServer",
125
132
  "McpTransport",
133
+ "ModelPricing",
126
134
  "NoopInstallation",
127
135
  "NullEventHandler",
128
136
  "NullSink",
@@ -130,6 +138,7 @@ __all__ = [
130
138
  "OutputSchema",
131
139
  "OutputSchemaStyle",
132
140
  "ParsedTurn",
141
+ "PricingTable",
133
142
  "Provider",
134
143
  "ProviderCapabilityError",
135
144
  "ProviderError",
@@ -148,6 +157,7 @@ __all__ = [
148
157
  "StdioMcpServer",
149
158
  "StreamParser",
150
159
  "TokenUsage",
160
+ "TokenWeights",
151
161
  "ToolCall",
152
162
  "ToolResult",
153
163
  "ToolTracker",
@@ -158,12 +168,16 @@ __all__ = [
158
168
  "__version__",
159
169
  "compact_json",
160
170
  "compose_event_handlers",
171
+ "cost_usd",
172
+ "default_pricing",
161
173
  "dialect_problems",
162
174
  "get_provider",
163
175
  "install_config_file",
164
176
  "interactive_env",
165
177
  "materialize",
166
178
  "normalize",
179
+ "normalized_usage",
167
180
  "parse_json_object",
181
+ "price_for",
168
182
  "provider_names",
169
183
  ]
@@ -50,12 +50,13 @@ from .mcp import (
50
50
  StdioMcpServer,
51
51
  install_config_file,
52
52
  )
53
+ from .pricing import ModelPricing, PricingTable, cost_usd, default_pricing, price_for
53
54
  from .profile import McpMechanism, OutputSchemaStyle, ProviderProfile, SchemaDialect
54
55
  from .provider import ArgvContext, McpInstallation, ParsedTurn, Provider, StreamParser
55
56
  from .schema import compact_json, dialect_problems, materialize, normalize
56
57
  from .stream import ToolTracker, parse_json_object
57
58
  from .turn import OutputSchema, TurnRequest, TurnResult
58
- from .usage import ProviderUsage, TokenUsage
59
+ from .usage import ProviderUsage, TokenUsage, TokenWeights, normalized_usage
59
60
 
60
61
  __all__ = [
61
62
  "AgentEvent",
@@ -79,11 +80,13 @@ __all__ = [
79
80
  "McpMechanism",
80
81
  "McpServer",
81
82
  "McpTransport",
83
+ "ModelPricing",
82
84
  "NoopInstallation",
83
85
  "NullEventHandler",
84
86
  "OutputSchema",
85
87
  "OutputSchemaStyle",
86
88
  "ParsedTurn",
89
+ "PricingTable",
87
90
  "Provider",
88
91
  "ProviderCapabilityError",
89
92
  "ProviderError",
@@ -101,6 +104,7 @@ __all__ = [
101
104
  "StdioMcpServer",
102
105
  "StreamParser",
103
106
  "TokenUsage",
107
+ "TokenWeights",
104
108
  "ToolCall",
105
109
  "ToolResult",
106
110
  "ToolTracker",
@@ -109,10 +113,14 @@ __all__ = [
109
113
  "UsageReport",
110
114
  "compact_json",
111
115
  "compose_event_handlers",
116
+ "cost_usd",
117
+ "default_pricing",
112
118
  "dialect_problems",
113
119
  "install_config_file",
114
120
  "interactive_env",
115
121
  "materialize",
116
122
  "normalize",
123
+ "normalized_usage",
117
124
  "parse_json_object",
125
+ "price_for",
118
126
  ]
@@ -0,0 +1,466 @@
1
+ """Static, versioned per-model token prices, and the cost of a :class:`TokenUsage`.
2
+
3
+ The table is data: one :class:`ModelPricing` per ``(vendor, model)``, each
4
+ with the official page it was read from and the date it was checked, and a
5
+ table-level ``version`` and ``last_updated``. Prices are USD per million
6
+ tokens at the vendor's standard (non-batch, global, short-context) tier.
7
+
8
+ A lookup of a model the table does not know returns ``None``: an unknown
9
+ price is never read as zero. Callers pin or extend assumptions by passing
10
+ their own :class:`PricingTable` (see :meth:`PricingTable.with_entries` and
11
+ :meth:`PricingTable.from_dict`).
12
+
13
+ Updating the table is described in ``docs/pricing.md``.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import datetime as dt
19
+ import math
20
+ import re
21
+ from dataclasses import dataclass, field, replace
22
+ from typing import TYPE_CHECKING, Any, cast
23
+
24
+ from agentshim.core.usage import TokenUsage, TokenWeights
25
+
26
+ if TYPE_CHECKING:
27
+ from collections.abc import Iterable, Mapping
28
+
29
+ #: Vendor each agentshim provider bills through, when it bills per token.
30
+ #: Copilot bills premium requests and Gemini is not priced here, so both are absent.
31
+ PROVIDER_VENDORS: Mapping[str, str] = {
32
+ "codex": "openai",
33
+ "openai": "openai",
34
+ "claude": "anthropic",
35
+ "anthropic": "anthropic",
36
+ }
37
+
38
+ _TOKENS_PER_UNIT = 1_000_000
39
+ _DATE_SUFFIX = re.compile(r"-\d{8}$")
40
+ _BRACKET_SUFFIX = re.compile(r"\[[^\]]*\]$")
41
+
42
+ OPENAI_PRICING_URL = "https://developers.openai.com/api/docs/pricing"
43
+ ANTHROPIC_PRICING_URL = "https://platform.claude.com/docs/en/about-claude/pricing"
44
+
45
+
46
+ @dataclass(frozen=True)
47
+ class ModelPricing:
48
+ """One model's USD price per million tokens, with where and when it was read.
49
+
50
+ Attributes:
51
+ vendor: Who bills for the model (``"openai"``, ``"anthropic"``).
52
+ model: The model id as the vendor names it.
53
+ input_usd: Base (uncached) input.
54
+ cache_read_usd: Input read from the prompt cache.
55
+ cache_write_usd: Input written to the prompt cache (Anthropic's
56
+ five-minute TTL). A vendor with no cache-write surcharge lists its
57
+ base input rate here.
58
+ output_usd: Output, reasoning included.
59
+ source: URL of the official pricing page the numbers were read from.
60
+ checked: Date the numbers were last checked against ``source``.
61
+ cache_write_1h_usd: One-hour-TTL cache writes; ``None`` when the
62
+ vendor has no such tier (``cache_write_usd`` applies).
63
+ reasoning_usd: Reasoning output, when billed apart from output;
64
+ ``None`` means reasoning is billed as output.
65
+ estimated: Whether any number is an estimate rather than a published
66
+ price; ``note`` must then state the basis.
67
+ note: Caveats, such as a long-context surcharge the table leaves out.
68
+ """
69
+
70
+ vendor: str
71
+ model: str
72
+ input_usd: float
73
+ cache_read_usd: float
74
+ cache_write_usd: float
75
+ output_usd: float
76
+ source: str
77
+ checked: dt.date
78
+ cache_write_1h_usd: float | None = None
79
+ reasoning_usd: float | None = None
80
+ estimated: bool = False
81
+ note: str = ""
82
+
83
+ def __post_init__(self) -> None:
84
+ """Reject a missing source or date, and any price that is not a finite non-negative number."""
85
+ for name in ("vendor", "model"):
86
+ value: object = getattr(self, name)
87
+ if not isinstance(value, str) or not value.strip():
88
+ msg = f"{name} must be a non-empty string"
89
+ raise ValueError(msg)
90
+ source = cast("object", self.source)
91
+ if not isinstance(source, str) or not source.startswith("https://"):
92
+ msg = f"{self.vendor}/{self.model}: source must be an https:// URL"
93
+ raise ValueError(msg)
94
+ if not _is_date(self.checked):
95
+ msg = f"{self.vendor}/{self.model}: checked must be a datetime.date"
96
+ raise TypeError(msg)
97
+ for name in ("input_usd", "cache_read_usd", "cache_write_usd", "output_usd"):
98
+ _check_price(self, name, getattr(self, name))
99
+ for name in ("cache_write_1h_usd", "reasoning_usd"):
100
+ optional: object = getattr(self, name)
101
+ if optional is not None:
102
+ _check_price(self, name, optional)
103
+ if self.input_usd <= 0:
104
+ msg = f"{self.vendor}/{self.model}: input_usd must be positive"
105
+ raise ValueError(msg)
106
+ if self.estimated and not self.note.strip():
107
+ msg = f"{self.vendor}/{self.model}: an estimated entry must state its basis in note"
108
+ raise ValueError(msg)
109
+
110
+ @property
111
+ def key(self) -> tuple[str, str]:
112
+ """The ``(vendor, model)`` this entry prices."""
113
+ return (self.vendor, self.model)
114
+
115
+ def usd_weights(self) -> TokenWeights:
116
+ """Weights in USD per million tokens, for :meth:`TokenUsage.weighted_total`."""
117
+ return TokenWeights(
118
+ uncached_input=self.input_usd,
119
+ cache_read_input=self.cache_read_usd,
120
+ cache_write_input=self.cache_write_usd,
121
+ cache_write_1h_input=self.cache_write_1h_usd,
122
+ output=self.output_usd,
123
+ reasoning_output=self.reasoning_usd,
124
+ )
125
+
126
+ def relative_weights(self) -> TokenWeights:
127
+ """Weights as multiples of the base input rate (uncached input is 1.0)."""
128
+ base = self.input_usd
129
+
130
+ def rel(value: float | None) -> float | None:
131
+ return None if value is None else value / base
132
+
133
+ return TokenWeights(
134
+ uncached_input=1.0,
135
+ cache_read_input=self.cache_read_usd / base,
136
+ cache_write_input=self.cache_write_usd / base,
137
+ cache_write_1h_input=rel(self.cache_write_1h_usd),
138
+ output=self.output_usd / base,
139
+ reasoning_output=rel(self.reasoning_usd),
140
+ )
141
+
142
+ def to_dict(self) -> dict[str, Any]:
143
+ """A JSON-serializable mapping that :meth:`from_dict` reads back."""
144
+ return {
145
+ "vendor": self.vendor,
146
+ "model": self.model,
147
+ "input_usd": self.input_usd,
148
+ "cache_read_usd": self.cache_read_usd,
149
+ "cache_write_usd": self.cache_write_usd,
150
+ "cache_write_1h_usd": self.cache_write_1h_usd,
151
+ "output_usd": self.output_usd,
152
+ "reasoning_usd": self.reasoning_usd,
153
+ "source": self.source,
154
+ "checked": self.checked.isoformat(),
155
+ "estimated": self.estimated,
156
+ "note": self.note,
157
+ }
158
+
159
+ @classmethod
160
+ def from_dict(cls, data: Mapping[str, Any]) -> ModelPricing:
161
+ """Build an entry from a mapping; ``checked`` is an ISO date string or a date.
162
+
163
+ Raises:
164
+ ValueError, TypeError: A required field is missing or invalid.
165
+ """
166
+ missing = [
167
+ name
168
+ for name in (
169
+ "vendor",
170
+ "model",
171
+ "input_usd",
172
+ "cache_read_usd",
173
+ "cache_write_usd",
174
+ "output_usd",
175
+ "source",
176
+ "checked",
177
+ )
178
+ if data.get(name) is None
179
+ ]
180
+ if missing:
181
+ msg = f"pricing entry is missing {', '.join(missing)}"
182
+ raise ValueError(msg)
183
+ return cls(
184
+ vendor=str(data["vendor"]),
185
+ model=str(data["model"]),
186
+ input_usd=float(data["input_usd"]),
187
+ cache_read_usd=float(data["cache_read_usd"]),
188
+ cache_write_usd=float(data["cache_write_usd"]),
189
+ output_usd=float(data["output_usd"]),
190
+ source=str(data["source"]),
191
+ checked=_date(data["checked"], "checked"),
192
+ cache_write_1h_usd=_optional_float(data.get("cache_write_1h_usd")),
193
+ reasoning_usd=_optional_float(data.get("reasoning_usd")),
194
+ estimated=bool(data.get("estimated", False)),
195
+ note=str(data.get("note") or ""),
196
+ )
197
+
198
+
199
+ @dataclass(frozen=True)
200
+ class PricingTable:
201
+ """A versioned set of :class:`ModelPricing` entries.
202
+
203
+ Attributes:
204
+ version: Names this set of prices; change it whenever a price changes.
205
+ last_updated: Date the table as a whole was last updated. No entry
206
+ may have been checked later.
207
+ entries: One entry per ``(vendor, model)``.
208
+ """
209
+
210
+ version: str
211
+ last_updated: dt.date
212
+ entries: tuple[ModelPricing, ...] = field(default=())
213
+
214
+ def __post_init__(self) -> None:
215
+ """Reject a missing version or date, duplicate keys, and entries checked after ``last_updated``."""
216
+ version = cast("object", self.version)
217
+ if not isinstance(version, str) or not version.strip():
218
+ msg = "version must be a non-empty string"
219
+ raise ValueError(msg)
220
+ if not _is_date(self.last_updated):
221
+ msg = "last_updated must be a datetime.date"
222
+ raise TypeError(msg)
223
+ object.__setattr__(self, "entries", tuple(self.entries))
224
+ seen: set[tuple[str, str]] = set()
225
+ for entry in self.entries:
226
+ if not isinstance(cast("object", entry), ModelPricing):
227
+ msg = f"entries must be ModelPricing, got {entry!r}"
228
+ raise TypeError(msg)
229
+ if entry.key in seen:
230
+ msg = f"duplicate pricing entry for {entry.vendor}/{entry.model}"
231
+ raise ValueError(msg)
232
+ seen.add(entry.key)
233
+ if entry.checked > self.last_updated:
234
+ msg = (
235
+ f"{entry.vendor}/{entry.model} was checked {entry.checked}, "
236
+ f"after the table's last_updated {self.last_updated}"
237
+ )
238
+ raise ValueError(msg)
239
+
240
+ def get(self, vendor: str, model: str) -> ModelPricing | None:
241
+ """The entry for exactly ``(vendor, model)``, or ``None``."""
242
+ for entry in self.entries:
243
+ if entry.key == (vendor, model):
244
+ return entry
245
+ return None
246
+
247
+ def with_entries(
248
+ self,
249
+ entries: Iterable[ModelPricing],
250
+ *,
251
+ version: str,
252
+ last_updated: dt.date | None = None,
253
+ ) -> PricingTable:
254
+ """A new table with *entries* added, replacing any with the same key.
255
+
256
+ A changed table is a different set of prices, so it needs its own
257
+ ``version``. ``last_updated`` defaults to the latest of this table's
258
+ date and the new entries' ``checked`` dates.
259
+ """
260
+ added = tuple(entries)
261
+ by_key = {entry.key: entry for entry in self.entries}
262
+ by_key.update({entry.key: entry for entry in added})
263
+ latest = max([self.last_updated, *(entry.checked for entry in added)])
264
+ return replace(
265
+ self,
266
+ version=version,
267
+ last_updated=last_updated or latest,
268
+ entries=tuple(by_key.values()),
269
+ )
270
+
271
+ def to_dict(self) -> dict[str, Any]:
272
+ """A JSON-serializable mapping that :meth:`from_dict` reads back."""
273
+ return {
274
+ "version": self.version,
275
+ "last_updated": self.last_updated.isoformat(),
276
+ "entries": [entry.to_dict() for entry in self.entries],
277
+ }
278
+
279
+ @classmethod
280
+ def from_dict(cls, data: Mapping[str, Any]) -> PricingTable:
281
+ """Build a table from a mapping such as a parsed JSON or TOML file.
282
+
283
+ Raises:
284
+ ValueError, TypeError: ``version``, ``last_updated`` or an entry
285
+ field is missing or invalid.
286
+ """
287
+ if data.get("version") is None or data.get("last_updated") is None:
288
+ msg = "pricing table needs version and last_updated"
289
+ raise ValueError(msg)
290
+ raw_entries: object = data.get("entries") or []
291
+ if not isinstance(raw_entries, list):
292
+ msg = "pricing table entries must be a list"
293
+ raise TypeError(msg)
294
+ rows = cast("list[Mapping[str, Any]]", raw_entries)
295
+ return cls(
296
+ version=str(data["version"]),
297
+ last_updated=_date(data["last_updated"], "last_updated"),
298
+ entries=tuple(ModelPricing.from_dict(row) for row in rows),
299
+ )
300
+
301
+
302
+ def vendor_and_model(provider: str, model: str) -> tuple[str, str] | None:
303
+ """Resolve an agentshim provider and model name to a table key.
304
+
305
+ Accepts a provider name (``codex``, ``claude``) or a vendor name, and a
306
+ model with a ``vendor/`` prefix (opencode's ``provider/model``). Drops a
307
+ trailing date snapshot (``claude-haiku-4-5-20251001``) and a bracketed
308
+ variant (``claude-opus-5-5[1m]``). Returns ``None`` when the provider is
309
+ not billed per token by a known vendor.
310
+ """
311
+ name = model.strip().lower()
312
+ vendor = PROVIDER_VENDORS.get(provider.strip().lower())
313
+ if "/" in name:
314
+ prefix, _, rest = name.partition("/")
315
+ vendor = PROVIDER_VENDORS.get(prefix, vendor)
316
+ name = rest
317
+ if vendor is None or not name:
318
+ return None
319
+ name = _BRACKET_SUFFIX.sub("", name)
320
+ name = _DATE_SUFFIX.sub("", name)
321
+ return (vendor, name)
322
+
323
+
324
+ def price_for(
325
+ provider: str, model: str | None, table: PricingTable | None = None
326
+ ) -> ModelPricing | None:
327
+ """The price of *model* run through *provider*, or ``None`` when it is not known.
328
+
329
+ Never falls back to another model's price or to zero.
330
+ """
331
+ if not model:
332
+ return None
333
+ key = vendor_and_model(provider, model)
334
+ if key is None:
335
+ return None
336
+ return (table or DEFAULT_PRICING).get(*key)
337
+
338
+
339
+ def default_pricing() -> PricingTable:
340
+ """The table agentshim ships (:data:`DEFAULT_PRICING`)."""
341
+ return DEFAULT_PRICING
342
+
343
+
344
+ def cost_usd(usage: TokenUsage, pricing: ModelPricing) -> float:
345
+ """USD cost of *usage* at *pricing*.
346
+
347
+ Every token class is priced once: uncached input, cache reads, cache
348
+ writes by TTL, and output (reasoning at ``reasoning_usd`` when set).
349
+ """
350
+ return usage.weighted_total(pricing.usd_weights()) / _TOKENS_PER_UNIT
351
+
352
+
353
+ def _check_price(entry: ModelPricing, name: str, value: object) -> None:
354
+ if isinstance(value, bool) or not isinstance(value, (int, float)):
355
+ msg = f"{entry.vendor}/{entry.model}: {name} must be a number, got {value!r}"
356
+ raise TypeError(msg)
357
+ if not math.isfinite(value) or value < 0:
358
+ msg = f"{entry.vendor}/{entry.model}: {name} must be finite and non-negative"
359
+ raise ValueError(msg)
360
+
361
+
362
+ def _is_date(value: object) -> bool:
363
+ return isinstance(value, dt.date) and not isinstance(value, dt.datetime)
364
+
365
+
366
+ def _optional_float(value: object) -> float | None:
367
+ return None if value is None else float(value) # pyright: ignore[reportArgumentType]
368
+
369
+
370
+ def _date(value: object, name: str) -> dt.date:
371
+ if isinstance(value, dt.datetime):
372
+ msg = f"{name} must be a date, not a datetime"
373
+ raise TypeError(msg)
374
+ if isinstance(value, dt.date):
375
+ return value
376
+ if isinstance(value, str):
377
+ return dt.date.fromisoformat(value)
378
+ msg = f"{name} must be an ISO date string or a date, got {value!r}"
379
+ raise TypeError(msg)
380
+
381
+
382
+ _CHECKED = dt.date(2026, 9, 27)
383
+ _OPENAI_NOTE = (
384
+ "Short-context standard tier; the long-context rates on the same page are not modelled."
385
+ )
386
+ _OPENAI_NO_WRITE_NOTE = (
387
+ "Short-context standard tier; the page lists no cache-write rate, so cache writes "
388
+ "are priced at base input."
389
+ )
390
+
391
+
392
+ def _openai(
393
+ model: str,
394
+ input_usd: float,
395
+ cache_read_usd: float,
396
+ output_usd: float,
397
+ cache_write_usd: float | None = None,
398
+ ) -> ModelPricing:
399
+ return ModelPricing(
400
+ vendor="openai",
401
+ model=model,
402
+ input_usd=input_usd,
403
+ cache_read_usd=cache_read_usd,
404
+ cache_write_usd=input_usd if cache_write_usd is None else cache_write_usd,
405
+ output_usd=output_usd,
406
+ source=OPENAI_PRICING_URL,
407
+ checked=_CHECKED,
408
+ note=_OPENAI_NOTE if cache_write_usd is not None else _OPENAI_NO_WRITE_NOTE,
409
+ )
410
+
411
+
412
+ def _anthropic( # noqa: PLR0913 - one positional per price column
413
+ model: str,
414
+ input_usd: float,
415
+ cache_write_usd: float,
416
+ cache_write_1h_usd: float,
417
+ cache_read_usd: float,
418
+ output_usd: float,
419
+ ) -> ModelPricing:
420
+ return ModelPricing(
421
+ vendor="anthropic",
422
+ model=model,
423
+ input_usd=input_usd,
424
+ cache_read_usd=cache_read_usd,
425
+ cache_write_usd=cache_write_usd,
426
+ cache_write_1h_usd=cache_write_1h_usd,
427
+ output_usd=output_usd,
428
+ source=ANTHROPIC_PRICING_URL,
429
+ checked=_CHECKED,
430
+ note="Claude API first-party, global inference; thinking is billed as output.",
431
+ )
432
+
433
+
434
+ #: The table agentshim ships. Update it as described in ``docs/pricing.md``.
435
+ DEFAULT_PRICING = PricingTable(
436
+ version="2026-09-27",
437
+ last_updated=_CHECKED,
438
+ entries=(
439
+ # OpenAI (Codex). Reasoning tokens are part of output and billed as output.
440
+ _openai("gpt-6-astra", 10.00, 1.00, 50.00, cache_write_usd=12.50),
441
+ _openai("gpt-6-sol", 2.00, 0.20, 10.00, cache_write_usd=2.50),
442
+ _openai("gpt-6-luna", 0.10, 0.01, 0.50, cache_write_usd=0.125),
443
+ _openai("gpt-5.6-sol", 4.00, 0.40, 20.00, cache_write_usd=5.00),
444
+ _openai("gpt-5.6-terra", 2.00, 0.20, 12.00, cache_write_usd=2.50),
445
+ _openai("gpt-5.6-luna", 0.20, 0.02, 1.20, cache_write_usd=0.25),
446
+ _openai("gpt-5.5", 5.00, 0.50, 30.00),
447
+ _openai("gpt-5.4", 2.50, 0.25, 15.00),
448
+ _openai("gpt-5.4-mini", 0.75, 0.075, 4.50),
449
+ _openai("gpt-5.4-nano", 0.20, 0.02, 1.25),
450
+ _openai("gpt-5.3-codex", 1.75, 0.175, 14.00),
451
+ _openai("gpt-5.2", 1.75, 0.175, 14.00),
452
+ _openai("gpt-5.1", 1.25, 0.125, 10.00),
453
+ _openai("gpt-5", 1.25, 0.125, 10.00),
454
+ _openai("gpt-5-mini", 0.25, 0.025, 2.00),
455
+ _openai("gpt-5-nano", 0.05, 0.005, 0.40),
456
+ # Anthropic (Claude): input, 5m write, 1h write, cache read, output.
457
+ _anthropic("claude-fable-5-1", 10.00, 12.50, 20.00, 0.25, 50.00),
458
+ _anthropic("claude-fable-5", 10.00, 12.50, 20.00, 1.00, 50.00),
459
+ _anthropic("claude-opus-5-5", 4.00, 5.00, 8.00, 0.20, 20.00),
460
+ _anthropic("claude-opus-5", 5.00, 6.25, 10.00, 0.50, 25.00),
461
+ _anthropic("claude-opus-4-8", 5.00, 6.25, 10.00, 0.50, 25.00),
462
+ _anthropic("claude-sonnet-5", 2.00, 2.50, 4.00, 0.20, 10.00),
463
+ _anthropic("claude-sonnet-4-6", 3.00, 3.75, 6.00, 0.30, 15.00),
464
+ _anthropic("claude-haiku-4-5", 1.00, 1.25, 2.00, 0.10, 5.00),
465
+ ),
466
+ )