avp-cli 0.1.0__py3-none-any.whl

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.
avp/pricing.py ADDED
@@ -0,0 +1,138 @@
1
+ """Shared pricing for AVP agents.
2
+
3
+ Why this lives in `avp` core, not per-agent:
4
+ - Anthropic models are reachable through nearly every mainstream agent
5
+ SDK: the raw Anthropic SDK, the Claude Agent SDK, LangChain /
6
+ LangGraph, LlamaIndex, Pydantic AI, the OpenAI-compatible surfaces on
7
+ Bedrock and Vertex, plus the long tail of provider-agnostic
8
+ frameworks. Every AVP adapter that wraps one of these SDKs needs the
9
+ same model-price lookup; a per-adapter copy drifts the moment
10
+ Anthropic ships new pricing.
11
+ - The price table is data, not policy. Adapters load it at startup,
12
+ users can override via the public `PriceTable` type, and the on-wire
13
+ cost number is tagged with `avp.cost.source` so downstreams can tell
14
+ a locally-computed number from a provider-reported one (or unknown).
15
+
16
+ Default ships in `avp/data/prices.json`, mirrored from models.dev and
17
+ keyed by its `<provider>/<model>` id (synced by `scripts/sync-prices.py`);
18
+ `load_default_prices()` reads it fresh on each call. `compute_cost`
19
+ normalizes a wire model (plus its provider) to a key here. To override,
20
+ pass a `PriceTable` to your driver / translator at construction.
21
+
22
+ `COST_SOURCE_*` constants name the audit-source values:
23
+ - `computed`: we did the math locally from a price table
24
+ - `reported`: the API/SDK handed us the number directly
25
+ - `unknown`: no price found and no provider report (cost reported as 0.0)
26
+
27
+ The agent stamps `avp.cost.source` on each `assistant_message` event so
28
+ trajectory consumers can filter / weight by provenance, e.g. an audit
29
+ pipeline that trusts reported numbers but flags computed numbers from a
30
+ stale price table.
31
+ """
32
+
33
+ from __future__ import annotations
34
+
35
+ import json
36
+ from collections.abc import Mapping
37
+ from importlib import resources
38
+ from typing import Literal
39
+
40
+ from pydantic import BaseModel, ConfigDict, Field
41
+
42
+ CostSource = Literal["computed", "reported", "unknown"]
43
+
44
+ COST_SOURCE_COMPUTED: CostSource = "computed"
45
+ COST_SOURCE_REPORTED: CostSource = "reported"
46
+ COST_SOURCE_UNKNOWN: CostSource = "unknown"
47
+
48
+
49
+ class ModelPrice(BaseModel):
50
+ """Per-1M-token pricing in USD."""
51
+
52
+ model_config = ConfigDict(frozen=True, extra="forbid")
53
+
54
+ input: float = Field(ge=0)
55
+ output: float = Field(ge=0)
56
+ cache_read: float = Field(default=0.0, ge=0)
57
+ cache_write: float = Field(default=0.0, ge=0)
58
+
59
+
60
+ PriceTable = Mapping[str, ModelPrice]
61
+
62
+
63
+ def load_default_prices() -> dict[str, ModelPrice]:
64
+ """Load the bundled default price table from `avp/data/prices.json`.
65
+
66
+ Reads on each call so users who patch the file in-place get fresh
67
+ numbers without restarting their process. Returns a fresh dict;
68
+ callers can mutate without side effects.
69
+ """
70
+ raw = resources.files("avp.data").joinpath("prices.json").read_text()
71
+ parsed = json.loads(raw)
72
+ return {
73
+ model: ModelPrice.model_validate(spec) for model, spec in parsed.get("models", {}).items()
74
+ }
75
+
76
+
77
+ def resolve_price(prices: PriceTable, model: str, provider: str | None = None) -> ModelPrice | None:
78
+ """Resolve a price by the model the agent put on the wire.
79
+
80
+ The bundled table is mirrored from models.dev and keyed by its
81
+ `<provider>/<model>` id. A wire `model` is either a slug already
82
+ containing a provider (`openai/gpt-4o`, used as-is) or a bare
83
+ provider-native string (`claude-sonnet-4-6`) qualified with
84
+ `provider` to form the key (`anthropic/claude-sonnet-4-6`). The
85
+ exact string is tried first so a custom table keyed by bare names
86
+ still works.
87
+
88
+ Gateway caveat: the key is the model's origin slug, so when a Commission
89
+ routes a model through a different storefront (`model: "openai/gpt-4o"` with
90
+ `provider.id: "openrouter"`), this returns the model's *list* price, not the
91
+ gateway's actual price (gateways add margin). Treat the result as a
92
+ best-effort estimate and prefer provider-reported cost when available
93
+ (`avp.cost.source = "reported"`).
94
+ """
95
+ p = prices.get(model)
96
+ if p is not None:
97
+ return p
98
+ if provider and "/" not in model:
99
+ return prices.get(f"{provider}/{model}")
100
+ return None
101
+
102
+
103
+ def compute_cost(
104
+ model: str,
105
+ *,
106
+ provider: str | None = None,
107
+ input_tokens: int,
108
+ output_tokens: int,
109
+ cache_read: int,
110
+ cache_write: int,
111
+ prices: PriceTable,
112
+ ) -> tuple[float, CostSource]:
113
+ """Compute billable USD cost from a turn's token counts.
114
+
115
+ Returns `(cost, source)` so callers can stamp `avp.cost.source` on
116
+ the wire alongside `avp.cost_usd`.
117
+
118
+ `provider` is the model's provider (e.g. `"anthropic"`), used to
119
+ resolve a bare wire model against the `<provider>/<model>`-keyed
120
+ table; omit it if the wire model is already provider-qualified.
121
+
122
+ `input_tokens` here is the AVP convention (cache reads INCLUDED).
123
+ Cache reads / writes are billed at their own per-token rates; the
124
+ fresh portion gets the regular input rate. If the model isn't in
125
+ the table, returns `(0.0, "unknown")` — caller should warn so
126
+ silent under-counts don't ship.
127
+ """
128
+ p = resolve_price(prices, model, provider)
129
+ if p is None:
130
+ return 0.0, COST_SOURCE_UNKNOWN
131
+ fresh = max(0, input_tokens - cache_read - cache_write)
132
+ cost = (
133
+ fresh * p.input / 1_000_000
134
+ + cache_read * p.cache_read / 1_000_000
135
+ + cache_write * p.cache_write / 1_000_000
136
+ + output_tokens * p.output / 1_000_000
137
+ )
138
+ return cost, COST_SOURCE_COMPUTED
avp/sink.py ADDED
@@ -0,0 +1,62 @@
1
+ """avp.sink — Event-sink type and built-in NDJSON sinks.
2
+
3
+ An :data:`EventSink` is any async callable that consumes one trajectory
4
+ event at a time. It owns serialization and I/O (stdout, file, DB, etc.)
5
+ so the surrounding agent stays I/O-agnostic.
6
+
7
+ Two built-ins are provided:
8
+
9
+ - :func:`stdio_sink` writes NDJSON to stdout (local runs, examples).
10
+ - :func:`jsonl_sink` writes NDJSON to a file (agent implementors
11
+ satisfying the conformance ``run --out <path.jsonl>`` contract).
12
+ """
13
+
14
+ import json
15
+ from collections.abc import Awaitable, Callable
16
+ from pathlib import Path
17
+
18
+ from avp.trajectory import Event
19
+
20
+ EventSink = Callable[[Event], Awaitable[None]]
21
+ """Async callable that consumes one trajectory event at a time."""
22
+
23
+
24
+ def _serialize(event: Event) -> str:
25
+ """Serialize an event to its canonical NDJSON line (no trailing newline).
26
+
27
+ Uses ``by_alias=True`` so dotted CloudEvents / AVP wire keys
28
+ (``avp.correlation_id`` etc.) round-trip in their canonical form.
29
+ """
30
+ return json.dumps(event.model_dump(by_alias=True, exclude_none=True, mode="json"))
31
+
32
+
33
+ async def stdio_sink(event: Event) -> None:
34
+ """Built-in :data:`EventSink`: print one event as NDJSON to stdout."""
35
+ print(_serialize(event), flush=True)
36
+
37
+
38
+ def jsonl_sink(path: Path) -> EventSink:
39
+ """Build an :data:`EventSink` that writes events to a JSONL file at ``path``.
40
+
41
+ The file is truncated on call so each run replaces any prior content,
42
+ then each event is appended in its own ``open("a") / write / close``
43
+ cycle. That makes progress visible to a concurrent reader (e.g. ``tail
44
+ -f``) without relying on OS-level buffer flushing. Used by agent
45
+ implementors to satisfy the conformance ``run --out <path>`` contract.
46
+
47
+ Example::
48
+
49
+ from avp.sink import jsonl_sink
50
+
51
+ async def run(commission, out_path):
52
+ sink = jsonl_sink(out_path)
53
+ await sink(some_event)
54
+ ...
55
+ """
56
+ path.open("w").close() # reset file
57
+
58
+ async def sink(event: Event) -> None:
59
+ with path.open("a") as file:
60
+ file.write(_serialize(event) + "\n")
61
+
62
+ return sink