diff --git a/Cargo.lock b/Cargo.lock index a4b85d7..6194ab9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -40,7 +40,7 @@ checksum = "e9d4ee0d472d1cd2e28c97dfa124b3d8d992e10eb0a035f33f5d12e3a177ba3b" [[package]] name = "amplifier-core" -version = "1.4.1" +version = "1.5.0" dependencies = [ "chrono", "log", @@ -76,7 +76,7 @@ dependencies = [ [[package]] name = "amplifier-core-py" -version = "1.4.1" +version = "1.5.0" dependencies = [ "amplifier-core", "log", diff --git a/bindings/python/Cargo.toml b/bindings/python/Cargo.toml index 01a0059..fbe7aff 100644 --- a/bindings/python/Cargo.toml +++ b/bindings/python/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "amplifier-core-py" -version = "1.4.1" +version = "1.5.0" edition = "2021" description = "PyO3 bridge for amplifier-core Rust kernel" license = "MIT" diff --git a/bindings/python/tests/test_cost_models.py b/bindings/python/tests/test_cost_models.py new file mode 100644 index 0000000..003f2a6 --- /dev/null +++ b/bindings/python/tests/test_cost_models.py @@ -0,0 +1,143 @@ +"""Tests for cost_usd fields on Usage and SessionStatus. + +Verifies: +- cost_usd is a declared Decimal field (not bag extra) +- Pydantic validates Decimal type — rejects float +- None means unknown (not zero) +- Decimal("0") means explicitly free +- SessionStatus.estimated_cost is removed (was never populated) +""" + +from decimal import Decimal + +import pytest +from pydantic import ValidationError + +from amplifier_core.message_models import Usage +from amplifier_core.models import SessionStatus + + +class TestUsageCostUsd: + def test_cost_usd_defaults_to_none(self): + """cost_usd is None when not provided — all existing Usage construction is unaffected.""" + usage = Usage(input_tokens=100, output_tokens=50, total_tokens=150) + assert usage.cost_usd is None + + def test_cost_usd_accepts_decimal(self): + """cost_usd should accept a Decimal value.""" + usage = Usage( + input_tokens=100, + output_tokens=50, + total_tokens=150, + cost_usd=Decimal("0.047832"), + ) + assert usage.cost_usd == Decimal("0.047832") + assert isinstance(usage.cost_usd, Decimal) + + def test_cost_usd_accepts_decimal_zero(self): + """Decimal('0') is valid — means explicitly free (not unknown).""" + usage = Usage( + input_tokens=0, output_tokens=0, total_tokens=0, cost_usd=Decimal("0") + ) + assert usage.cost_usd == Decimal("0") + assert usage.cost_usd is not None # None != 0 + + def test_cost_usd_rejects_float(self): + """Float must be rejected — Pydantic should raise ValidationError for float input.""" + with pytest.raises(ValidationError): + Usage( + input_tokens=100, + output_tokens=50, + total_tokens=150, + cost_usd=0.047, # float — not acceptable for monetary values + ) + + def test_cost_usd_accepts_decimal_from_string(self): + """Decimal coercion from string is acceptable (event dict transport pattern).""" + usage = Usage( + input_tokens=100, + output_tokens=50, + total_tokens=150, + cost_usd="0.0478", # raw string, as it would arrive from a JSON dict + ) + assert usage.cost_usd == Decimal("0.0478") + assert isinstance(usage.cost_usd, Decimal) + + def test_none_is_not_zero(self): + """Explicit contract: None (unknown) != Decimal('0') (free).""" + unknown = Usage(input_tokens=1, output_tokens=1, total_tokens=2) + free = Usage( + input_tokens=0, output_tokens=0, total_tokens=0, cost_usd=Decimal("0") + ) + assert unknown.cost_usd is None + assert free.cost_usd == Decimal("0") + assert unknown.cost_usd != free.cost_usd + + def test_model_dump_includes_cost_usd_as_decimal(self): + """model_dump() should include cost_usd as Decimal (not string, not float).""" + usage = Usage( + input_tokens=100, + output_tokens=50, + total_tokens=150, + cost_usd=Decimal("0.047"), + ) + dumped = usage.model_dump() + assert "cost_usd" in dumped + assert isinstance(dumped["cost_usd"], Decimal) + + def test_model_dump_json_mode_serializes_cost_usd_as_string(self): + """model_dump(mode='json') serializes Decimal as string for JSON safety.""" + usage = Usage( + input_tokens=100, + output_tokens=50, + total_tokens=150, + cost_usd=Decimal("0.047"), + ) + dumped = usage.model_dump(mode="json") + assert isinstance(dumped["cost_usd"], str) + assert dumped["cost_usd"] == "0.047" + + def test_cost_usd_not_in_dump_when_none(self): + """When cost_usd is None, model_dump(exclude_none=True) omits it.""" + usage = Usage(input_tokens=100, output_tokens=50, total_tokens=150) + dumped = usage.model_dump(exclude_none=True) + assert "cost_usd" not in dumped + + +class TestSessionStatusCostUsd: + def test_cost_usd_defaults_to_none(self): + status = SessionStatus(session_id="test-123") + assert status.cost_usd is None + + def test_cost_usd_accepts_decimal(self): + status = SessionStatus(session_id="test-123", cost_usd=Decimal("1.234567")) + assert status.cost_usd == Decimal("1.234567") + assert isinstance(status.cost_usd, Decimal) + + def test_cost_usd_rejects_float(self): + with pytest.raises(ValidationError): + SessionStatus(session_id="test-123", cost_usd=1.23) + + def test_to_dict_includes_cost_usd_as_string(self): + """to_dict() uses mode='json' — cost_usd should serialize as string.""" + status = SessionStatus(session_id="test-123", cost_usd=Decimal("2.50")) + d = status.to_dict() + assert "cost_usd" in d + assert isinstance(d["cost_usd"], str) + assert d["cost_usd"] == "2.50" + +class TestSchemaSync: + def test_session_status_schema_has_cost_usd(self): + """JSON schema for SessionStatus must include cost_usd.""" + schema = SessionStatus.model_json_schema() + props = schema.get("properties", {}) + assert "cost_usd" in props, f"cost_usd missing. Keys: {list(props.keys())}" + + def test_session_status_schema_cost_usd_is_string_type(self): + """In JSON schema, cost_usd should be string (Decimal serializes as string).""" + schema = SessionStatus.model_json_schema() + cost_prop = schema["properties"]["cost_usd"] + types = [t.get("type") for t in cost_prop.get("anyOf", [cost_prop])] + assert "string" in types or cost_prop.get("type") == "string", ( + f"cost_usd schema type should include 'string', got: {cost_prop}" + ) diff --git a/crates/amplifier-core/Cargo.toml b/crates/amplifier-core/Cargo.toml index ba61d61..2a9bf44 100644 --- a/crates/amplifier-core/Cargo.toml +++ b/crates/amplifier-core/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "amplifier-core" -version = "1.4.1" +version = "1.5.0" edition = "2021" description = "Pure Rust kernel for the Amplifier modular AI agent system" license = "MIT" diff --git a/crates/amplifier-core/src/models.rs b/crates/amplifier-core/src/models.rs index b18b576..266c62e 100644 --- a/crates/amplifier-core/src/models.rs +++ b/crates/amplifier-core/src/models.rs @@ -451,9 +451,19 @@ pub struct SessionStatus { pub total_output_tokens: i64, // Cost tracking - /// Estimated cost (if available). - #[serde(default)] - pub estimated_cost: Option, + /// Accumulated session cost in USD stored as a high-precision decimal string + /// (e.g., "0.047832"). None means rate data was unavailable — not zero cost. + /// + /// Stored as String (not rust_decimal::Decimal) deliberately: + /// - The kernel does not perform arithmetic on cost — it only stores and passes it. + /// - Type enforcement (Decimal, float rejection) is the responsibility of the + /// Python layer where cost enters and exits the system. + /// - Avoids a rust_decimal dependency in the kernel for a transport-only field. + /// + /// If cost arithmetic is ever needed inside the kernel, change this type to + /// rust_decimal::Decimal and add the rust_decimal crate to Cargo.toml. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub cost_usd: Option, // Last activity /// Last activity timestamp (ISO 8601 string). @@ -866,7 +876,7 @@ mod tests { tool_failures: 1, total_input_tokens: 1000, total_output_tokens: 500, - estimated_cost: Some(0.05), + cost_usd: None, last_activity: Some("2025-01-01T00:01:00Z".into()), last_error: None, }; @@ -875,7 +885,6 @@ mod tests { assert_eq!(deserialized.session_id, "sess-123"); assert_eq!(deserialized.status, SessionState::Running); assert_eq!(deserialized.total_messages, 5); - assert_eq!(deserialized.estimated_cost, Some(0.05)); } #[test] @@ -887,4 +896,18 @@ mod tests { assert_eq!(status.tool_invocations, 0); assert!(status.ended_at.is_none()); } + #[test] + fn session_status_cost_usd_roundtrip() { + // Verifies cost_usd: Option is present and roundtrips correctly. + // String type matches Decimal JSON serialization on the Python side. + let json = + r#"{"session_id": "s1", "started_at": "2025-01-01T00:00:00Z", "cost_usd": "0.047832"}"#; + let status: SessionStatus = serde_json::from_str(json).unwrap(); + assert_eq!(status.cost_usd, Some("0.047832".to_string())); + + // None when absent (unknown cost, not zero) + let json_no_cost = r#"{"session_id": "s2", "started_at": "2025-01-01T00:00:00Z"}"#; + let status_no_cost: SessionStatus = serde_json::from_str(json_no_cost).unwrap(); + assert!(status_no_cost.cost_usd.is_none()); + } } diff --git a/pyproject.toml b/pyproject.toml index 30bcf95..4c3df7c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "amplifier-core" -version = "1.4.1" +version = "1.5.0" description = "Rust kernel with Python bindings for the Amplifier modular AI agent framework" license = "MIT" readme = "README.md" diff --git a/python/amplifier_core/__init__.py b/python/amplifier_core/__init__.py index 4e80cd3..42a6e58 100644 --- a/python/amplifier_core/__init__.py +++ b/python/amplifier_core/__init__.py @@ -6,7 +6,7 @@ AmplifierSession`) still give the pure-Python implementations. """ -__version__ = "1.0.7" +__version__ = "1.5.0" # --- Rust-backed primary types (THE SWITCHOVER) --- # These four were previously imported from their Python submodules. diff --git a/python/amplifier_core/message_models.py b/python/amplifier_core/message_models.py index 359312a..d7e069a 100644 --- a/python/amplifier_core/message_models.py +++ b/python/amplifier_core/message_models.py @@ -14,6 +14,8 @@ - docs/schemas/request_envelope_v1.json for JSON schema """ +from decimal import Decimal + from typing import Annotated from typing import Any from typing import Literal @@ -22,6 +24,7 @@ from pydantic import BaseModel from pydantic import ConfigDict from pydantic import Field +from pydantic import field_validator class TextBlock(BaseModel): @@ -242,6 +245,24 @@ class Usage(BaseModel): reasoning_tokens: int | None = None cache_read_tokens: int | None = None cache_write_tokens: int | None = None + cost_usd: Decimal | None = Field( + default=None, + description=( + "Message cost in USD. " + "None = rate data unavailable (not zero). " + "Populated by provider." + ), + ) + + @field_validator("cost_usd", mode="before") + @classmethod + def reject_float_cost(cls, v): + if isinstance(v, float): + raise ValueError( + "cost_usd must be Decimal, not float. " + "Use Decimal('0.047') — floats lose monetary precision." + ) + return v class Degradation(BaseModel): diff --git a/python/amplifier_core/models.py b/python/amplifier_core/models.py index 92c4e7b..bb7faa0 100644 --- a/python/amplifier_core/models.py +++ b/python/amplifier_core/models.py @@ -6,11 +6,13 @@ import json import re from datetime import datetime +from decimal import Decimal from typing import Any from typing import Literal from pydantic import BaseModel from pydantic import Field +from pydantic import field_validator def _sanitize_for_llm(text: str) -> str: @@ -417,8 +419,24 @@ class SessionStatus(BaseModel): total_input_tokens: int = 0 total_output_tokens: int = 0 - # Cost tracking (if available) - estimated_cost: float | None = None + # Cost tracking + cost_usd: Decimal | None = Field( + default=None, + description=( + "Accumulated session cost in USD. " + "None = rate data unavailable (not zero). " + "Populated by provider session contributors." + ), + ) + @field_validator("cost_usd", mode="before") + @classmethod + def reject_float_cost_usd(cls, v): + if isinstance(v, float): + raise ValueError( + "cost_usd must be Decimal, not float. " + "Use Decimal('1.23') — floats lose monetary precision." + ) + return v # Last activity last_activity: datetime | None = None diff --git a/scripts/bump_version.py b/scripts/bump_version.py index ee31b1b..3249896 100644 --- a/scripts/bump_version.py +++ b/scripts/bump_version.py @@ -29,10 +29,11 @@ ("pyproject.toml", 3), ("crates/amplifier-core/Cargo.toml", 3), ("bindings/python/Cargo.toml", 3), + ("python/amplifier_core/__init__.py", 9), ] SEMVER_RE = re.compile(r"^\d+\.\d+\.\d+$") -VERSION_LINE_RE = re.compile(r'^(version\s*=\s*")([^"]+)(")', re.MULTILINE) +VERSION_LINE_RE = re.compile(r'^((?:__)?version\s*=\s*")([^"]+)(")', re.MULTILINE) def die(msg: str) -> NoReturn: diff --git a/uv.lock b/uv.lock index 34fc671..ba87867 100644 --- a/uv.lock +++ b/uv.lock @@ -4,7 +4,7 @@ requires-python = ">=3.11" [[package]] name = "amplifier-core" -version = "1.4.1" +version = "1.5.0" source = { editable = "." } dependencies = [ { name = "click" },