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.
- {agentshim-0.6.8 → agentshim-0.7.0}/CHANGELOG.md +52 -0
- {agentshim-0.6.8 → agentshim-0.7.0}/PKG-INFO +4 -3
- {agentshim-0.6.8 → agentshim-0.7.0}/README.md +3 -2
- {agentshim-0.6.8 → agentshim-0.7.0}/agentshim/__init__.py +15 -1
- {agentshim-0.6.8 → agentshim-0.7.0}/agentshim/core/__init__.py +9 -1
- agentshim-0.7.0/agentshim/core/pricing.py +466 -0
- agentshim-0.7.0/agentshim/core/usage.py +287 -0
- {agentshim-0.6.8 → agentshim-0.7.0}/agentshim/providers/claude/parser.py +18 -10
- {agentshim-0.6.8 → agentshim-0.7.0}/agentshim/providers/claude/scripted.py +8 -4
- {agentshim-0.6.8 → agentshim-0.7.0}/agentshim/providers/codex/events.py +7 -2
- {agentshim-0.6.8 → agentshim-0.7.0}/agentshim/providers/codex/parser.py +9 -6
- {agentshim-0.6.8 → agentshim-0.7.0}/agentshim/providers/codex/scripted.py +13 -4
- {agentshim-0.6.8 → agentshim-0.7.0}/agentshim/providers/copilot/parser.py +7 -8
- {agentshim-0.6.8 → agentshim-0.7.0}/agentshim/providers/copilot/scripted.py +2 -3
- {agentshim-0.6.8 → agentshim-0.7.0}/agentshim/providers/gemini/parser.py +9 -8
- {agentshim-0.6.8 → agentshim-0.7.0}/agentshim/providers/opencode/parser.py +9 -8
- {agentshim-0.6.8 → agentshim-0.7.0}/agentshim/providers/opencode/scripted.py +3 -3
- {agentshim-0.6.8 → agentshim-0.7.0}/pyproject.toml +1 -1
- agentshim-0.6.8/agentshim/core/usage.py +0 -78
- {agentshim-0.6.8 → agentshim-0.7.0}/.gitignore +0 -0
- {agentshim-0.6.8 → agentshim-0.7.0}/agentshim/agent.py +0 -0
- {agentshim-0.6.8 → agentshim-0.7.0}/agentshim/core/_files.py +0 -0
- {agentshim-0.6.8 → agentshim-0.7.0}/agentshim/core/env.py +0 -0
- {agentshim-0.6.8 → agentshim-0.7.0}/agentshim/core/errors.py +0 -0
- {agentshim-0.6.8 → agentshim-0.7.0}/agentshim/core/events.py +0 -0
- {agentshim-0.6.8 → agentshim-0.7.0}/agentshim/core/mcp.py +0 -0
- {agentshim-0.6.8 → agentshim-0.7.0}/agentshim/core/profile.py +0 -0
- {agentshim-0.6.8 → agentshim-0.7.0}/agentshim/core/provider.py +0 -0
- {agentshim-0.6.8 → agentshim-0.7.0}/agentshim/core/schema.py +0 -0
- {agentshim-0.6.8 → agentshim-0.7.0}/agentshim/core/stream.py +0 -0
- {agentshim-0.6.8 → agentshim-0.7.0}/agentshim/core/turn.py +0 -0
- {agentshim-0.6.8 → agentshim-0.7.0}/agentshim/execution/__init__.py +0 -0
- {agentshim-0.6.8 → agentshim-0.7.0}/agentshim/execution/executor.py +0 -0
- {agentshim-0.6.8 → agentshim-0.7.0}/agentshim/execution/host.py +0 -0
- {agentshim-0.6.8 → agentshim-0.7.0}/agentshim/execution/transform.py +0 -0
- {agentshim-0.6.8 → agentshim-0.7.0}/agentshim/providers/__init__.py +0 -0
- {agentshim-0.6.8 → agentshim-0.7.0}/agentshim/providers/claude/__init__.py +0 -0
- {agentshim-0.6.8 → agentshim-0.7.0}/agentshim/providers/claude/events.py +0 -0
- {agentshim-0.6.8 → agentshim-0.7.0}/agentshim/providers/claude/hooks/__init__.py +0 -0
- {agentshim-0.6.8 → agentshim-0.7.0}/agentshim/providers/claude/hooks/confine_reads.py +0 -0
- {agentshim-0.6.8 → agentshim-0.7.0}/agentshim/providers/claude/provider.py +0 -0
- {agentshim-0.6.8 → agentshim-0.7.0}/agentshim/providers/claude/sandbox.py +0 -0
- {agentshim-0.6.8 → agentshim-0.7.0}/agentshim/providers/claude/user_hooks.py +0 -0
- {agentshim-0.6.8 → agentshim-0.7.0}/agentshim/providers/codex/__init__.py +0 -0
- {agentshim-0.6.8 → agentshim-0.7.0}/agentshim/providers/codex/_toml.py +0 -0
- {agentshim-0.6.8 → agentshim-0.7.0}/agentshim/providers/codex/provider.py +0 -0
- {agentshim-0.6.8 → agentshim-0.7.0}/agentshim/providers/codex/rules.py +0 -0
- {agentshim-0.6.8 → agentshim-0.7.0}/agentshim/providers/codex/sandbox.py +0 -0
- {agentshim-0.6.8 → agentshim-0.7.0}/agentshim/providers/copilot/__init__.py +0 -0
- {agentshim-0.6.8 → agentshim-0.7.0}/agentshim/providers/copilot/events.py +0 -0
- {agentshim-0.6.8 → agentshim-0.7.0}/agentshim/providers/copilot/provider.py +0 -0
- {agentshim-0.6.8 → agentshim-0.7.0}/agentshim/providers/gemini/__init__.py +0 -0
- {agentshim-0.6.8 → agentshim-0.7.0}/agentshim/providers/gemini/events.py +0 -0
- {agentshim-0.6.8 → agentshim-0.7.0}/agentshim/providers/gemini/provider.py +0 -0
- {agentshim-0.6.8 → agentshim-0.7.0}/agentshim/providers/gemini/scripted.py +0 -0
- {agentshim-0.6.8 → agentshim-0.7.0}/agentshim/providers/opencode/__init__.py +0 -0
- {agentshim-0.6.8 → agentshim-0.7.0}/agentshim/providers/opencode/events.py +0 -0
- {agentshim-0.6.8 → agentshim-0.7.0}/agentshim/providers/opencode/provider.py +0 -0
- {agentshim-0.6.8 → agentshim-0.7.0}/agentshim/py.typed +0 -0
- {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.
|
|
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
|
|
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
|
|
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.
|
|
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
|
+
)
|