Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion bindings/python/Cargo.toml
Original file line number Diff line number Diff line change
@@ -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"
Expand Down
143 changes: 143 additions & 0 deletions bindings/python/tests/test_cost_models.py
Original file line number Diff line number Diff line change
@@ -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}"
)
2 changes: 1 addition & 1 deletion crates/amplifier-core/Cargo.toml
Original file line number Diff line number Diff line change
@@ -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"
Expand Down
33 changes: 28 additions & 5 deletions crates/amplifier-core/src/models.rs
Original file line number Diff line number Diff line change
Expand Up @@ -451,9 +451,19 @@ pub struct SessionStatus {
pub total_output_tokens: i64,

// Cost tracking
/// Estimated cost (if available).
#[serde(default)]
pub estimated_cost: Option<f64>,
/// 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<String>,

// Last activity
/// Last activity timestamp (ISO 8601 string).
Expand Down Expand Up @@ -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,
};
Expand All @@ -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]
Expand All @@ -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<String> 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());
}
}
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -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"
Expand Down
2 changes: 1 addition & 1 deletion python/amplifier_core/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
21 changes: 21 additions & 0 deletions python/amplifier_core/message_models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -22,6 +24,7 @@
from pydantic import BaseModel
from pydantic import ConfigDict
from pydantic import Field
from pydantic import field_validator


class TextBlock(BaseModel):
Expand Down Expand Up @@ -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):
Expand Down
22 changes: 20 additions & 2 deletions python/amplifier_core/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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
Expand Down
3 changes: 2 additions & 1 deletion scripts/bump_version.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
2 changes: 1 addition & 1 deletion uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading